Commit d72ac926 authored by erio's avatar erio
Browse files

feat: image output token billing, channel-mapped billing source, credits balance precheck

- Parse candidatesTokensDetails from Gemini API to separate image/text output tokens
- Add image_output_tokens and image_output_cost to usage_log (migration 089)
- Support per-image-token pricing via output_cost_per_image_token from model pricing data
- Channel pricing ImageOutputPrice override works in token billing mode
- Auto-fill image_output_price in channel pricing form from model defaults
- Add "channel_mapped" billing model source as new default (migration 088)
- Bills by model name after channel mapping, before account mapping
- Fix channel cache error TTL sign error (115s → 5s)
- Fix Update channel only invalidating new groups, not removed groups
- Fix frontend model_mapping clearing sending undefined instead of {}
- Credits balance precheck via shared AccountUsageService cache before injection
- Skip credits injection for accounts with insufficient balance
- Don't mark credits exhausted for "exhausted your capacity on this model" 429s
parent 2555951b
......@@ -134,6 +134,9 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
resolved.BasePricing.CacheReadPricePerToken = *chPricing.CacheReadPrice
resolved.BasePricing.CacheReadPricePerTokenPriority = *chPricing.CacheReadPrice
}
if chPricing.ImageOutputPrice != nil {
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
}
}
// applyRequestTierOverrides 应用按次/图片模式的渠道覆盖
......
......@@ -204,6 +204,7 @@ type OpenAIUsage struct {
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"`
CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"`
ImageOutputTokens int `json:"image_output_tokens,omitempty"`
}
// OpenAIForwardResult represents the result of forwarding
......@@ -4177,6 +4178,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
}
// Get rate multiplier
......@@ -4195,6 +4197,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
if result.BillingModel != "" {
billingModel = strings.TrimSpace(result.BillingModel)
}
if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" {
billingModel = input.ChannelMappedModel
}
if input.BillingModelSource == "requested" && input.OriginalModel != "" {
billingModel = input.OriginalModel
}
......@@ -4255,8 +4260,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
OutputTokens: result.Usage.OutputTokens,
CacheCreationTokens: result.Usage.CacheCreationInputTokens,
CacheReadTokens: result.Usage.CacheReadInputTokens,
ImageOutputTokens: result.Usage.ImageOutputTokens,
InputCost: cost.InputCost,
OutputCost: cost.OutputCost,
ImageOutputCost: cost.ImageOutputCost,
CacheCreationCost: cost.CacheCreationCost,
CacheReadCost: cost.CacheReadCost,
TotalCost: cost.TotalCost,
......
......@@ -71,6 +71,7 @@ type LiteLLMModelPricing struct {
Mode string `json:"mode"`
SupportsPromptCaching bool `json:"supports_prompt_caching"`
OutputCostPerImage float64 `json:"output_cost_per_image"` // 图片生成模型每张图片价格
OutputCostPerImageToken float64 `json:"output_cost_per_image_token"` // 图片输出 token 价格
}
// PricingRemoteClient 远程价格数据获取接口
......@@ -94,6 +95,7 @@ type LiteLLMRawEntry struct {
Mode string `json:"mode"`
SupportsPromptCaching bool `json:"supports_prompt_caching"`
OutputCostPerImage *float64 `json:"output_cost_per_image"`
OutputCostPerImageToken *float64 `json:"output_cost_per_image_token"`
}
// PricingService 动态价格服务
......@@ -408,6 +410,9 @@ func (s *PricingService) parsePricingData(body []byte) (map[string]*LiteLLMModel
if entry.OutputCostPerImage != nil {
pricing.OutputCostPerImage = *entry.OutputCostPerImage
}
if entry.OutputCostPerImageToken != nil {
pricing.OutputCostPerImageToken = *entry.OutputCostPerImageToken
}
result[modelName] = pricing
}
......
......@@ -134,6 +134,9 @@ type UsageLog struct {
CacheCreation5mTokens int `gorm:"column:cache_creation_5m_tokens"`
CacheCreation1hTokens int `gorm:"column:cache_creation_1h_tokens"`
ImageOutputTokens int
ImageOutputCost float64
InputCost float64
OutputCost float64
CacheCreationCost float64
......
-- Change default billing_model_source for new channels to 'channel_mapped'
-- Existing channels keep their current setting (no UPDATE on existing rows)
ALTER TABLE channels ALTER COLUMN billing_model_source SET DEFAULT 'channel_mapped';
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_output_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS image_output_cost DECIMAL(20, 10) NOT NULL DEFAULT 0;
......@@ -134,6 +134,7 @@ export interface ModelDefaultPricing {
output_price?: number
cache_write_price?: number
cache_read_price?: number
image_output_price?: number
}
export async function getModelDefaultPricing(model: string): Promise<ModelDefaultPricing> {
......
......@@ -328,6 +328,7 @@ async function onModelsUpdate(newModels: string[]) {
output_price: perTokenToMTok(result.output_price ?? null),
cache_write_price: perTokenToMTok(result.cache_write_price ?? null),
cache_read_price: perTokenToMTok(result.cache_read_price ?? null),
image_output_price: perTokenToMTok(result.image_output_price ?? null),
})
}
} catch {
......
......@@ -1800,6 +1800,7 @@ export default {
mappingSource: 'Source model',
mappingTarget: 'Target model',
billingModelSource: 'Billing Model',
billingModelSourceChannelMapped: 'Bill by channel-mapped model',
billingModelSourceRequested: 'Bill by requested model',
billingModelSourceUpstream: 'Bill by final upstream model',
billingModelSourceHint: 'Controls which model name is used for pricing lookup',
......
......@@ -1880,6 +1880,7 @@ export default {
mappingSource: '源模型',
mappingTarget: '目标模型',
billingModelSource: '计费基准',
billingModelSourceChannelMapped: '以渠道映射后的模型计费',
billingModelSourceRequested: '以请求模型计费',
billingModelSourceUpstream: '以最终模型计费',
billingModelSourceHint: '控制使用哪个模型名称进行定价查找',
......
......@@ -471,6 +471,7 @@ const statusEditOptions = computed(() => [
])
const billingModelSourceOptions = computed(() => [
{ value: 'channel_mapped', label: t('admin.channels.form.billingModelSourceChannelMapped', 'Bill by channel-mapped model') },
{ value: 'requested', label: t('admin.channels.form.billingModelSourceRequested', 'Bill by requested model') },
{ value: 'upstream', label: t('admin.channels.form.billingModelSourceUpstream', 'Bill by final upstream model') }
])
......@@ -504,7 +505,7 @@ const form = reactive({
description: '',
status: 'active',
restrict_models: false,
billing_model_source: 'requested' as string,
billing_model_source: 'channel_mapped' as string,
platforms: [] as PlatformSection[]
})
......@@ -819,7 +820,7 @@ function resetForm() {
form.description = ''
form.status = 'active'
form.restrict_models = false
form.billing_model_source = 'requested'
form.billing_model_source = 'channel_mapped'
form.platforms = []
activeTab.value = 'basic'
}
......@@ -837,7 +838,7 @@ async function openEditDialog(channel: Channel) {
form.description = channel.description || ''
form.status = channel.status
form.restrict_models = channel.restrict_models || false
form.billing_model_source = channel.billing_model_source || 'requested'
form.billing_model_source = channel.billing_model_source || 'channel_mapped'
// Must load groups first so apiToForm can map groupID → platform
await loadGroups()
form.platforms = apiToForm(channel)
......@@ -932,7 +933,7 @@ async function handleSubmit() {
status: form.status,
group_ids,
model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : undefined,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models
}
......@@ -944,7 +945,7 @@ async function handleSubmit() {
description: form.description.trim() || undefined,
group_ids,
model_pricing,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : undefined,
model_mapping: Object.keys(model_mapping).length > 0 ? model_mapping : {},
billing_model_source: form.billing_model_source,
restrict_models: form.restrict_models
}
......
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