fix: 长上下文默认保持开启,并与 OpenAI 账号开关取交集

迁移默认 false 会让存量分组静默丢掉 ≥200k 阶梯与渠道多档价。
OpenAI 路径传入 Group 后,账号级 openai_long_context_billing_enabled 被顶掉。

- 列默认 TRUE,并对存量行回填
- 分组价卡不再手填 token 区间,勾选走官方/预设阶梯
- 分组开关与账号开关 AND;价卡 JSON 损坏时打 warn
This commit is contained in:
IanShaw027
2026-08-13 08:38:10 +08:00
parent 8c4c3c09ce
commit b830bc14d6
12 changed files with 134 additions and 36 deletions
+1 -1
View File
@@ -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},
+2 -2
View File
@@ -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"}).
+6 -1
View File
@@ -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,
@@ -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)
@@ -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) {
@@ -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,
@@ -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(
@@ -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';
@@ -134,8 +134,8 @@
</div>
</div>
<!-- Token intervals -->
<div class="mt-3">
<!-- Token intervals (channel-only; group long-context uses official presets) -->
<div v-if="!hideTokenIntervals" class="mt-3">
<div class="flex items-center justify-between">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.channels.form.intervals') }}
@@ -245,10 +245,13 @@ import channelsAPI from '@/api/admin/channels'
const { t } = useI18n()
const props = defineProps<{
const props = withDefaults(defineProps<{
entry: PricingFormEntry
platform?: string
}>()
hideTokenIntervals?: boolean
}>(), {
hideTokenIntervals: false,
})
const emit = defineEmits<{
update: [entry: PricingFormEntry]
@@ -1007,9 +1007,9 @@ export default {
},
modelPricing: {
title: 'Per-model group pricing',
description: 'Overrides channel and built-in prices for matching models. Use per-request tiers such as realtime, tts, and stt for audio.',
description: 'Overrides channel and built-in prices for matching models. Long-context tiers come from official presets — do not enter custom intervals. Use per-request tiers such as realtime, tts, and stt for audio.',
longContext: 'Enable long-context tier pricing',
longContextHint: 'When disabled, token models always use the lowest configured tier.',
longContextHint: 'When checked, official/preset long-context tiers apply. When unchecked, token models stay on the first-tier base rate.',
add: 'Add model price'
},
voicePricing: {
@@ -1004,9 +1004,9 @@ export default {
},
modelPricing: {
title: '分组逐模型定价',
description: '匹配模型后覆盖渠道和内置价格。音频可用按次层级配置 realtime、tts、stt。',
description: '匹配模型后覆盖渠道和内置价格。长上下文阶梯沿用官方/预设价卡,无需再手填区间。音频可用按次层级配置 realtime、tts、stt。',
longContext: '启用长上下文阶梯定价',
longContextHint: '关闭时,Token 模型始终只使用配置的最低档。',
longContextHint: '勾选后按官方/预设阶梯计费;关闭则始终按第一档基础价。',
add: '添加模型价格'
},
voicePricing: {
+11 -8
View File
@@ -1499,7 +1499,7 @@
<span><span class="block text-sm text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.longContext") }}</span><span class="block text-xs text-gray-500">{{ t("admin.groups.modelPricing.longContextHint") }}</span></span>
</label>
<div class="mt-3 space-y-2">
<PricingEntryCard v-for="(entry, index) in createForm.model_pricing" :key="index" :entry="entry" :platform="createForm.platform" @update="createForm.model_pricing[index] = $event" @remove="createForm.model_pricing.splice(index, 1)" />
<PricingEntryCard v-for="(entry, index) in createForm.model_pricing" :key="index" :entry="entry" :platform="createForm.platform" hide-token-intervals @update="createForm.model_pricing[index] = $event" @remove="createForm.model_pricing.splice(index, 1)" />
</div>
</div>
@@ -3221,7 +3221,7 @@
<span><span class="block text-sm text-gray-700 dark:text-gray-300">{{ t("admin.groups.modelPricing.longContext") }}</span><span class="block text-xs text-gray-500">{{ t("admin.groups.modelPricing.longContextHint") }}</span></span>
</label>
<div class="mt-3 space-y-2">
<PricingEntryCard v-for="(entry, index) in editForm.model_pricing" :key="index" :entry="entry" :platform="editForm.platform" @update="editForm.model_pricing[index] = $event" @remove="editForm.model_pricing.splice(index, 1)" />
<PricingEntryCard v-for="(entry, index) in editForm.model_pricing" :key="index" :entry="entry" :platform="editForm.platform" hide-token-intervals @update="editForm.model_pricing[index] = $event" @remove="editForm.model_pricing.splice(index, 1)" />
</div>
</div>
@@ -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;