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 @@ - -