Commit 2355029d authored by erio's avatar erio
Browse files

fix: validate empty intervals + antigravity platform pricing match

- Backend: reject intervals with all-null price fields on save
- Backend: filterValidIntervals skips empty intervals in pricing resolver
- Frontend: red border + asterisk on empty interval rows
- Backend: antigravity groups now match anthropic/gemini channel pricing
parent 8d25335b
...@@ -2,6 +2,7 @@ package admin ...@@ -2,6 +2,7 @@ package admin
import ( import (
"errors" "errors"
"fmt"
"strconv" "strconv"
"strings" "strings"
...@@ -233,10 +234,26 @@ func validatePricingBillingMode(pricing []service.ChannelModelPricing) error { ...@@ -233,10 +234,26 @@ func validatePricingBillingMode(pricing []service.ChannelModelPricing) error {
return errors.New("per-request price or intervals required for per_request/image billing mode") return errors.New("per-request price or intervals required for per_request/image billing mode")
} }
} }
// 校验 interval:至少有一个价格字段非空
for _, iv := range p.Intervals {
if iv.InputPrice == nil && iv.OutputPrice == nil &&
iv.CacheWritePrice == nil && iv.CacheReadPrice == nil &&
iv.PerRequestPrice == nil {
return fmt.Errorf("interval [%d, %s] has no price fields set for model %v",
iv.MinTokens, formatMaxTokens(iv.MaxTokens), p.Models)
}
}
} }
return nil return nil
} }
func formatMaxTokens(max *int) string {
if max == nil {
return "∞"
}
return fmt.Sprintf("%d", *max)
}
// --- Handlers --- // --- Handlers ---
// List handles listing channels with pagination // List handles listing channels with pagination
......
...@@ -106,9 +106,12 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI ...@@ -106,9 +106,12 @@ func (r *ModelPricingResolver) applyChannelOverrides(ctx context.Context, groupI
// applyTokenOverrides 应用 token 模式的渠道覆盖 // applyTokenOverrides 应用 token 模式的渠道覆盖
func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) { func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricing, resolved *ResolvedPricing) {
// 如果有区间定价,使用区间 // 过滤掉所有价格字段都为空的无效 interval
if len(chPricing.Intervals) > 0 { validIntervals := filterValidIntervals(chPricing.Intervals)
resolved.Intervals = chPricing.Intervals
// 如果有有效的区间定价,使用区间
if len(validIntervals) > 0 {
resolved.Intervals = validIntervals
return return
} }
...@@ -147,6 +150,20 @@ func (r *ModelPricingResolver) applyRequestTierOverrides(chPricing *ChannelModel ...@@ -147,6 +150,20 @@ func (r *ModelPricingResolver) applyRequestTierOverrides(chPricing *ChannelModel
} }
} }
// filterValidIntervals 过滤掉所有价格字段都为空的无效 interval。
// 前端可能创建了只有 min/max 但无价格的空 interval。
func filterValidIntervals(intervals []PricingInterval) []PricingInterval {
var valid []PricingInterval
for _, iv := range intervals {
if iv.InputPrice != nil || iv.OutputPrice != nil ||
iv.CacheWritePrice != nil || iv.CacheReadPrice != nil ||
iv.PerRequestPrice != nil {
valid = append(valid, iv)
}
}
return valid
}
// GetIntervalPricing 根据 context token 数获取区间定价。 // GetIntervalPricing 根据 context token 数获取区间定价。
// 如果有区间列表,找到匹配区间并构造 ModelPricing;否则直接返回 BasePricing。 // 如果有区间列表,找到匹配区间并构造 ModelPricing;否则直接返回 BasePricing。
func (r *ModelPricingResolver) GetIntervalPricing(resolved *ResolvedPricing, totalContextTokens int) *ModelPricing { func (r *ModelPricingResolver) GetIntervalPricing(resolved *ResolvedPricing, totalContextTokens int) *ModelPricing {
......
<template> <template>
<div class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2 dark:border-dark-500 dark:bg-dark-700"> <div class="flex items-start gap-2 rounded border p-2"
:class="isEmpty ? 'border-red-400 bg-red-50 dark:border-red-500 dark:bg-red-950/20' : 'border-gray-200 bg-white dark:border-dark-500 dark:bg-dark-700'">
<!-- Token mode: context range + prices ($/MTok) --> <!-- Token mode: context range + prices ($/MTok) -->
<template v-if="mode === 'token'"> <template v-if="mode === 'token'">
<div class="w-20"> <div class="w-20">
...@@ -13,12 +14,12 @@ ...@@ -13,12 +14,12 @@
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" /> type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }} <span class="text-gray-300">$/M</span></label> <label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice', '输入') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<input :value="interval.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)" <input :value="interval.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" /> type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }} <span class="text-gray-300">$/M</span></label> <label class="text-xs text-gray-400">{{ t('admin.channels.form.outputPrice', '输出') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$/M</span></label>
<input :value="interval.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)" <input :value="interval.output_price" @input="emitField('output_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" /> type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div> </div>
...@@ -54,7 +55,7 @@ ...@@ -54,7 +55,7 @@
type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" /> type="number" min="0" class="input mt-0.5 text-xs" :placeholder="'∞'" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice', '单次价格') }} <span class="text-gray-300">$</span></label> <label class="text-xs text-gray-400">{{ t('admin.channels.form.perRequestPrice', '单次价格') }} <span v-if="isEmpty" class="text-red-500">*</span> <span class="text-gray-300">$</span></label>
<input :value="interval.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)" <input :value="interval.per_request_price" @input="emitField('per_request_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-xs" /> type="number" step="any" min="0" class="input mt-0.5 text-xs" />
</div> </div>
...@@ -67,6 +68,7 @@ ...@@ -67,6 +68,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import type { IntervalFormEntry } from './types' import type { IntervalFormEntry } from './types'
...@@ -84,6 +86,16 @@ const emit = defineEmits<{ ...@@ -84,6 +86,16 @@ const emit = defineEmits<{
remove: [] remove: []
}>() }>()
// 检测所有价格字段是否都为空
const isEmpty = computed(() => {
const iv = props.interval
return (iv.input_price == null || iv.input_price === '') &&
(iv.output_price == null || iv.output_price === '') &&
(iv.cache_write_price == null || iv.cache_write_price === '') &&
(iv.cache_read_price == null || iv.cache_read_price === '') &&
(iv.per_request_price == null || iv.per_request_price === '')
})
function emitField(field: keyof IntervalFormEntry, value: string | number | null) { function emitField(field: keyof IntervalFormEntry, value: string | number | null) {
emit('update', { ...props.interval, [field]: value === '' ? null : value }) emit('update', { ...props.interval, [field]: value === '' ? null : value })
} }
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment