From b830bc14d655524357360df1e4301b9cf81fb1fc Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Thu, 13 Aug 2026 08:38:10 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=95=BF=E4=B8=8A=E4=B8=8B=E6=96=87?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E4=BF=9D=E6=8C=81=E5=BC=80=E5=90=AF=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=B8=8E=20OpenAI=20=E8=B4=A6=E5=8F=B7=E5=BC=80?= =?UTF-8?q?=E5=85=B3=E5=8F=96=E4=BA=A4=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移默认 false 会让存量分组静默丢掉 ≥200k 阶梯与渠道多档价。 OpenAI 路径传入 Group 后,账号级 openai_long_context_billing_enabled 被顶掉。 - 列默认 TRUE,并对存量行回填 - 分组价卡不再手填 token 区间,勾选走官方/预设阶梯 - 分组开关与账号开关 AND;价卡 JSON 损坏时打 warn --- backend/ent/migrate/schema.go | 2 +- backend/ent/schema/group.go | 4 +- backend/internal/repository/api_key_repo.go | 7 +- .../service/model_pricing_resolver.go | 12 ++- .../service/model_pricing_resolver_test.go | 25 +++++-- .../openai_gateway_record_usage_test.go | 73 ++++++++++++++++++- .../internal/service/openai_gateway_usage.go | 1 + .../migrations/221_group_model_pricing.sql | 8 +- .../admin/channel/PricingEntryCard.vue | 11 ++- .../src/i18n/locales/en/admin/overview.ts | 4 +- .../src/i18n/locales/zh/admin/overview.ts | 4 +- frontend/src/views/admin/GroupsView.vue | 19 +++-- 12 files changed, 134 insertions(+), 36 deletions(-) diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index d19cb1e19a..316870e5a5 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -934,7 +934,7 @@ var ( {Name: "audio_realtime_price_per_min", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "audio_tts_price_per_million_chars", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "audio_stt_price_per_hour", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, - {Name: "long_context_pricing_enabled", Type: field.TypeBool, Default: false}, + {Name: "long_context_pricing_enabled", Type: field.TypeBool, Default: true}, {Name: "model_pricing", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}}, {Name: "claude_code_only", Type: field.TypeBool, Default: false}, {Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true}, diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index cafd6dcafe..f195ac51db 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -188,8 +188,8 @@ func (Group) Fields() []ent.Field { SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}). Comment("STT 每小时价格(USD)"), field.Bool("long_context_pricing_enabled"). - Default(false). - Comment("是否按上下文长度应用模型阶梯价格"), + Default(true). + Comment("是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价"), field.JSON("model_pricing", json.RawMessage{}). Optional(). SchemaType(map[string]string{dialect.Postgres: "jsonb"}). diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 777889b630..388562faeb 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "strings" "time" @@ -951,7 +952,11 @@ func groupEntityToService(g *dbent.Group) *service.Group { } var modelPricing []service.ChannelModelPricing if len(g.ModelPricing) > 0 { - _ = json.Unmarshal(g.ModelPricing, &modelPricing) + if err := json.Unmarshal(g.ModelPricing, &modelPricing); err != nil { + slog.Warn("group model_pricing unmarshal failed; falling back to channel/builtin pricing", + "group_id", g.ID, "error", err) + modelPricing = nil + } } return &service.Group{ ID: g.ID, diff --git a/backend/internal/service/model_pricing_resolver.go b/backend/internal/service/model_pricing_resolver.go index efee77e3e9..52c6b930c5 100644 --- a/backend/internal/service/model_pricing_resolver.go +++ b/backend/internal/service/model_pricing_resolver.go @@ -71,11 +71,15 @@ type PricingInput struct { func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) *ResolvedPricing { longContextPricingEnabled := input.Group == nil || input.Group.LongContextPricingEnabled if groupPricing := matchGroupModelPricing(input.Group, input.Model); groupPricing != nil { + // Group token cards only override the first-tier / flat rates. + // Long-context ladders come from official presets, gated by the checkbox. + if groupPricing.BillingMode == "" || groupPricing.BillingMode == BillingModeToken { + stripped := groupPricing.Clone() + stripped.Intervals = nil + groupPricing = &stripped + } resolved := r.resolveConfiguredPricing(groupPricing, input.Model, PricingSourceGroup) resolved.longContextPricingEnabled = longContextPricingEnabled - if resolved.Mode == BillingModeToken && !longContextPricingEnabled { - r.applyFirstTokenTier(resolved, groupPricing) - } return resolved } @@ -119,7 +123,7 @@ func (r *ModelPricingResolver) Resolve(ctx context.Context, input PricingInput) if !longContextPricingEnabled { r.applyFirstTokenTier(resolved, chPricing) } - } else if input.GroupID != nil { + } else if input.GroupID != nil && r.channelService != nil { r.applyChannelOverrides(ctx, *input.GroupID, input.Model, resolved) if resolved.Source == PricingSourceChannel && !longContextPricingEnabled { r.applyFirstTokenTier(resolved, resolved.channelPricing) diff --git a/backend/internal/service/model_pricing_resolver_test.go b/backend/internal/service/model_pricing_resolver_test.go index 215d6f0ed1..11613fa9eb 100644 --- a/backend/internal/service/model_pricing_resolver_test.go +++ b/backend/internal/service/model_pricing_resolver_test.go @@ -850,24 +850,35 @@ func TestResolve_GroupPricingOverridesChannel(t *testing.T) { require.InDelta(t, 2e-6, resolved.BasePricing.OutputPricePerToken, 1e-12) } -func TestResolve_GroupLongContextToggleControlsIntervals(t *testing.T) { - r := NewModelPricingResolver(nil, newTestBillingServiceForResolver()) +func TestResolve_GroupLongContextUsesPresetNotCustomIntervals(t *testing.T) { + bs := newTestBillingServiceForResolver() + bs.fallbackPrices["claude-sonnet-4"].LongContextInputThreshold = 200000 + bs.fallbackPrices["claude-sonnet-4"].LongContextThresholdInclusive = true + bs.fallbackPrices["claude-sonnet-4"].LongContextInputMultiplier = 2 + bs.fallbackPrices["claude-sonnet-4"].LongContextOutputMultiplier = 2 + r := NewModelPricingResolver(nil, bs) group := &Group{ID: 100, ModelPricing: []ChannelModelPricing{{ Models: []string{"claude-sonnet-4"}, BillingMode: BillingModeToken, + InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6), Intervals: []PricingInterval{ - {MinTokens: 0, MaxTokens: testPtrInt(200000), InputPrice: testPtrFloat64(1e-6)}, - {MinTokens: 200000, InputPrice: testPtrFloat64(2e-6)}, + {MinTokens: 0, MaxTokens: testPtrInt(200000), InputPrice: testPtrFloat64(9e-6)}, + {MinTokens: 200000, InputPrice: testPtrFloat64(18e-6)}, }, }}} resolved := r.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", Group: group}) - require.Empty(t, resolved.Intervals) + require.False(t, resolved.longContextPricingEnabled) + require.Empty(t, resolved.Intervals, "group token intervals are not a user-facing long-context ladder") require.InDelta(t, 1e-6, r.GetIntervalPricing(resolved, 300000).InputPricePerToken, 1e-12) + require.Equal(t, 200000, resolved.BasePricing.LongContextInputThreshold) group.LongContextPricingEnabled = true resolved = r.Resolve(context.Background(), PricingInput{Model: "claude-sonnet-4", Group: group}) - require.Len(t, resolved.Intervals, 2) - require.InDelta(t, 2e-6, r.GetIntervalPricing(resolved, 300000).InputPricePerToken, 1e-12) + require.True(t, resolved.longContextPricingEnabled) + require.Empty(t, resolved.Intervals) + require.InDelta(t, 1e-6, r.GetIntervalPricing(resolved, 300000).InputPricePerToken, 1e-12) + require.Equal(t, 200000, resolved.BasePricing.LongContextInputThreshold) + require.InDelta(t, 2.0, resolved.BasePricing.LongContextInputMultiplier, 1e-12) } func TestCalculateCostUnified_UsesContinuousMediaUnits(t *testing.T) { diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 4ae09575e3..97ce3d185d 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -256,6 +256,17 @@ func newOpenAIRecordUsageServiceForTest(usageRepo UsageLogRepository, userRepo U return svc } +func openAIRecordUsageAPIKeyWithGroup(svc *OpenAIGatewayService, id int64, groupLongContext bool) *APIKey { + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + return &APIKey{ + ID: id, + Group: &Group{ + ID: 1, + LongContextPricingEnabled: groupLongContext, + }, + } +} + func newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo UsageLogRepository, billingRepo UsageBillingRepository, userRepo UserRepository, subRepo UserSubscriptionRepository, rateRepo UserGroupRateRepository) *OpenAIGatewayService { svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, rateRepo) svc.usageBillingRepo = billingRepo @@ -1088,7 +1099,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingDisabledByDefaul Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: &APIKey{ID: 1014}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1014, true), User: &User{ID: 2014}, Account: &Account{ID: 3014, Platform: PlatformOpenAI}, }) @@ -1122,7 +1133,7 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccoun Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: &APIKey{ID: 1015}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1015, true), User: &User{ID: 2015}, Account: &Account{ ID: 3015, @@ -1143,6 +1154,62 @@ func TestOpenAIGatewayServiceRecordUsage_Gpt54LongContextBillingEnabledPerAccoun require.True(t, usageRepo.lastLog.LongContextBillingApplied) } +func TestOpenAIGatewayServiceRecordUsage_GroupAndAccountLongContextMustBothAllow(t *testing.T) { + tokens := OpenAIUsage{InputTokens: 300000, OutputTokens: 2000} + baseInput := 300000 * 2.5e-6 + baseOutput := 2000 * 15e-6 + + t.Run("group on account off", func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{RequestID: "resp_and_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1020, true), + User: &User{ID: 2020}, + Account: &Account{ID: 3020, Platform: PlatformOpenAI}, + }) + 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) + }) + + t.Run("group off account on", func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{RequestID: "resp_and_group_off", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1021, false), + User: &User{ID: 2021}, + Account: &Account{ + ID: 3021, Platform: PlatformOpenAI, + Extra: map[string]any{"openai_long_context_billing_enabled": true}, + }, + }) + require.NoError(t, err) + require.False(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput, usageRepo.lastLog.InputCost, 1e-10) + }) + + t.Run("group on account on", func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{RequestID: "resp_and_on", Usage: tokens, Model: "gpt-5.4-2026-03-05", Duration: time.Second}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1022, true), + User: &User{ID: 2022}, + Account: &Account{ + ID: 3022, Platform: PlatformOpenAI, + Extra: map[string]any{"openai_long_context_billing_enabled": true}, + }, + }) + require.NoError(t, err) + require.True(t, usageRepo.lastLog.LongContextBillingApplied) + require.InDelta(t, baseInput*2, usageRepo.lastLog.InputCost, 1e-10) + require.InDelta(t, baseOutput*1.5, usageRepo.lastLog.OutputCost, 1e-10) + }) +} + func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSetting(t *testing.T) { tests := []struct { name string @@ -1177,7 +1244,7 @@ func TestOpenAIGatewayServiceRecordUsage_SparkShadowUsesCurrentParentBillingSett Model: "gpt-5.4-2026-03-05", Duration: time.Second, }, - APIKey: &APIKey{ID: 1016}, + APIKey: openAIRecordUsageAPIKeyWithGroup(svc, 1016, true), User: &User{ID: 2016}, Account: &Account{ ID: 3016, diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index a7691fe3e1..0b88439437 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -640,6 +640,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, }) } return s.billingService.calculateCostWithServiceTierPolicy( diff --git a/backend/migrations/221_group_model_pricing.sql b/backend/migrations/221_group_model_pricing.sql index 47498a4e47..52cf002891 100644 --- a/backend/migrations/221_group_model_pricing.sql +++ b/backend/migrations/221_group_model_pricing.sql @@ -1,8 +1,12 @@ ALTER TABLE groups - ADD COLUMN IF NOT EXISTS long_context_pricing_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS long_context_pricing_enabled BOOLEAN NOT NULL DEFAULT TRUE, ADD COLUMN IF NOT EXISTS model_pricing JSONB; +UPDATE groups +SET long_context_pricing_enabled = TRUE +WHERE long_context_pricing_enabled IS DISTINCT FROM TRUE; + COMMENT ON COLUMN groups.long_context_pricing_enabled IS - 'Whether token pricing selects long-context tiers; false uses the lowest tier only'; + 'Whether token pricing selects official/preset long-context tiers; default true preserves existing long-context billing'; COMMENT ON COLUMN groups.model_pricing IS 'Per-model group pricing overrides channel and built-in model pricing'; diff --git a/frontend/src/components/admin/channel/PricingEntryCard.vue b/frontend/src/components/admin/channel/PricingEntryCard.vue index abb10a9a4d..d09e19feb2 100644 --- a/frontend/src/components/admin/channel/PricingEntryCard.vue +++ b/frontend/src/components/admin/channel/PricingEntryCard.vue @@ -134,8 +134,8 @@ - -
+ +
- +
@@ -3221,7 +3221,7 @@ {{ t("admin.groups.modelPricing.longContext") }}{{ t("admin.groups.modelPricing.longContextHint") }}
- +
@@ -4527,7 +4527,10 @@ const groupPricingToAPI = ( image_input_price: mTokToPerToken(entry.image_input_price), image_output_price: mTokToPerToken(entry.image_output_price), per_request_price: toNullableNumber(entry.per_request_price), - intervals: formIntervalsToAPI(entry.intervals || []), + intervals: + entry.billing_mode === "token" + ? [] + : formIntervalsToAPI(entry.intervals || []), })); const { t } = useI18n(); @@ -5013,7 +5016,7 @@ const createForm = reactive({ daily_limit_usd: null as number | null, weekly_limit_usd: null as number | null, monthly_limit_usd: null as number | null, - long_context_pricing_enabled: false, + long_context_pricing_enabled: true, model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, @@ -5374,7 +5377,7 @@ const editForm = reactive({ daily_limit_usd: null as number | null, weekly_limit_usd: null as number | null, monthly_limit_usd: null as number | null, - long_context_pricing_enabled: false, + long_context_pricing_enabled: true, model_pricing: [] as PricingFormEntry[], // 图片生成计费配置 allow_image_generation: false, @@ -5848,7 +5851,7 @@ const closeCreateModal = () => { createForm.video_price_720p = null; createForm.video_price_1080p = null; createForm.video_model_prices = createVideoModelPricesForm(); - createForm.long_context_pricing_enabled = false; + createForm.long_context_pricing_enabled = true; createForm.model_pricing = []; createForm.web_search_price_per_call = null; createForm.search_price_per_1k = null; @@ -6076,7 +6079,7 @@ const handleEdit = async (group: AdminGroup) => { editForm.weekly_limit_usd = group.weekly_limit_usd; editForm.monthly_limit_usd = group.monthly_limit_usd; editForm.long_context_pricing_enabled = - group.long_context_pricing_enabled ?? false; + group.long_context_pricing_enabled ?? true; editForm.model_pricing = groupPricingFromAPI(group.model_pricing); editForm.allow_image_generation = group.allow_image_generation ?? false; editForm.allow_batch_image_generation = @@ -6182,7 +6185,7 @@ const closeEditModal = () => { editForm.video_price_720p = null; editForm.video_price_1080p = null; editForm.video_model_prices = createVideoModelPricesForm(); - editForm.long_context_pricing_enabled = false; + editForm.long_context_pricing_enabled = true; editForm.model_pricing = []; editForm.web_search_price_per_call = null; editForm.search_price_per_1k = null;