Merge pull request #4396 from StarryKira/fix/4386-image-input-pricing

fix(billing): 图像输入 token 按独立单价计费(gpt-image-2 图片编辑)
This commit is contained in:
Wesley Liddick
2026-07-16 09:32:29 +08:00
committed by GitHub
35 changed files with 287 additions and 35 deletions
+2
View File
@@ -30,6 +30,7 @@ export interface ChannelModelPricing {
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
image_input_price: number | null
image_output_price: number | null
per_request_price: number | null
intervals: PricingInterval[]
@@ -154,6 +155,7 @@ export interface ModelDefaultPricing {
output_price?: number
cache_write_price?: number
cache_read_price?: number
image_input_price?: number
image_output_price?: number
}
+1
View File
@@ -39,6 +39,7 @@ export interface UserSupportedModelPricing {
output_price: number | null
cache_write_price: number | null
cache_read_price: number | null
image_input_price: number | null
image_output_price: number | null
per_request_price: number | null
intervals: UserPricingInterval[]
@@ -101,7 +101,7 @@
{{ t('admin.channels.form.defaultPrices') }}
<span class="ml-1 font-normal text-gray-400">$/MTok</span>
</label>
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-5">
<div class="mt-1 grid grid-cols-2 gap-2 sm:grid-cols-6">
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.inputPrice') }}</label>
<input :value="entry.input_price" @input="emitField('input_price', ($event.target as HTMLInputElement).value)"
@@ -122,6 +122,11 @@
<input :value="entry.cache_read_price" @input="emitField('cache_read_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageInputPrice') }}</label>
<input :value="entry.image_input_price" @input="emitField('image_input_price', ($event.target as HTMLInputElement).value)"
type="number" step="any" min="0" class="input mt-0.5 text-sm" :placeholder="t('admin.channels.form.pricePlaceholder')" />
</div>
<div>
<label class="text-xs text-gray-400">{{ t('admin.channels.form.imageTokenPrice') }}</label>
<input :value="entry.image_output_price" @input="emitField('image_output_price', ($event.target as HTMLInputElement).value)"
@@ -328,6 +333,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_input_price: perTokenToMTok(result.image_input_price ?? null),
image_output_price: perTokenToMTok(result.image_output_price ?? null),
})
}
@@ -21,6 +21,7 @@ export interface PricingFormEntry {
output_price: number | string | null
cache_write_price: number | string | null
cache_read_price: number | string | null
image_input_price: number | string | null
image_output_price: number | string | null
per_request_price: number | string | null
intervals: IntervalFormEntry[]
@@ -144,6 +144,12 @@
<span v-if="row.cache_ttl_overridden" :title="t('usage.cacheTtlOverriddenHint')" class="inline-flex items-center rounded px-1 py-px text-[10px] font-medium leading-tight bg-rose-100 text-rose-600 ring-1 ring-inset ring-rose-200 dark:bg-rose-500/20 dark:text-rose-400 dark:ring-rose-500/30 cursor-help">R</span>
</div>
</div>
<div v-if="hasImageInputTokens(row)" class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-fuchsia-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
<span class="font-medium text-fuchsia-600 dark:text-fuchsia-400">{{ row.image_input_tokens.toLocaleString() }}</span>
</div>
</div>
<div v-if="hasImageOutputTokens(row)" class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-pink-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
@@ -246,10 +252,18 @@
<div class="space-y-1.5">
<div>
<div class="text-xs font-semibold text-gray-300 mb-1">{{ t('usage.tokenDetails') }}</div>
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<div v-if="tokenTooltipData && tokenTooltipData.input_tokens > 0 && !hasImageInputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageInputTokens(tokenTooltipData) && textInputTokens(tokenTooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.inputTokens') }}</span>
<span class="font-medium text-white">{{ textInputTokens(tokenTooltipData).toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && hasImageInputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageInputTokens') }}</span>
<span class="font-medium text-fuchsia-300">{{ tokenTooltipData.image_input_tokens.toLocaleString() }}</span>
</div>
<div v-if="tokenTooltipData && tokenTooltipData.output_tokens > 0 && !hasImageOutputTokens(tokenTooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputTokens') }}</span>
<span class="font-medium text-white">{{ tokenTooltipData.output_tokens.toLocaleString() }}</span>
@@ -327,6 +341,10 @@
<span class="text-gray-400">{{ t('admin.usage.inputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && hasImageInputCost(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageInputCost') }}</span>
<span class="font-medium text-fuchsia-300">${{ tooltipData.image_input_cost.toFixed(6) }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('admin.usage.outputCost') }}</span>
<span class="font-medium text-white">${{ tooltipData.output_cost.toFixed(6) }}</span>
@@ -337,9 +355,13 @@
</div>
<!-- Token billing: show unit prices per 1M tokens -->
<template v-if="tooltipData && !isImageUsage(tooltipData) && (!tooltipData.billing_mode || tooltipData.billing_mode === BILLING_MODE_TOKEN)">
<div v-if="tooltipData && tooltipData.input_tokens > 0" class="flex items-center justify-between gap-4">
<div v-if="tooltipData && textInputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.inputTokenPrice') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, tooltipData.input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
<span class="font-medium text-sky-300">{{ formatTokenPricePerMillion(tooltipData.input_cost, textInputTokens(tooltipData)) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && hasImageInputTokens(tooltipData)" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.imageInputTokenPrice') }}</span>
<span class="font-medium text-fuchsia-300">{{ formatTokenPricePerMillion(tooltipData.image_input_cost ?? 0, tooltipData.image_input_tokens) }} {{ t('usage.perMillionTokens') }}</span>
</div>
<div v-if="tooltipData && tooltipData.output_cost > 0 && textOutputTokens(tooltipData) > 0" class="flex items-center justify-between gap-4">
<span class="text-gray-400">{{ t('usage.outputTokenPrice') }}</span>
@@ -471,6 +493,9 @@ import {
hasImageOutputTokens,
textOutputTokens,
hasImageOutputCost,
hasImageInputTokens,
textInputTokens,
hasImageInputCost,
} from '@/utils/imageUsage'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
@@ -90,6 +90,13 @@
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
v-if="model.pricing.image_input_price != null && model.pricing.image_input_price > 0"
:label="t(prefixKey('imageInputPrice'))"
:value="model.pricing.image_input_price"
:unit="t(prefixKey('unitPerMillion'))"
:scale="perMillionScale"
/>
<PricingRow
v-if="model.pricing.image_output_price != null && model.pricing.image_output_price > 0"
:label="t(prefixKey('imageOutputPrice'))"
@@ -116,6 +116,7 @@ export default {
cacheReadPrice: 'Cache Read',
cacheWritePriceShort: 'Cache W',
cacheReadPriceShort: 'Cache R',
imageInputPrice: 'Image Input',
imageTokenPrice: 'Image Output',
imageOutputPrice: 'Image Output Price',
pricePlaceholder: 'Default',
@@ -325,6 +325,9 @@ export default {
imageBillingSize: 'Billing size',
imageInputSize: 'Input size',
imageOutputSize: 'Output size',
imageInputTokens: 'Image Input Tokens',
imageInputTokenPrice: 'Image Input Price',
imageInputCost: 'Image Input Cost',
imageOutputTokens: 'Image Output Tokens',
imageOutputTokenPrice: 'Image Output Price',
imageOutputCost: 'Image Output Cost',
@@ -499,6 +502,7 @@ export default {
outputPrice: 'Output',
cacheWritePrice: 'Cache Write',
cacheReadPrice: 'Cache Read',
imageInputPrice: 'Image Input',
imageOutputPrice: 'Image Output',
perRequestPrice: 'Per Request',
intervals: 'Tiered Pricing',
@@ -116,6 +116,7 @@ export default {
cacheReadPrice: '缓存读取',
cacheWritePriceShort: '缓存写',
cacheReadPriceShort: '缓存读',
imageInputPrice: '图片输入',
imageTokenPrice: '图片输出',
imageOutputPrice: '图片输出价格',
pricePlaceholder: '默认',
@@ -330,6 +330,9 @@ export default {
imageBillingSize: '计费尺寸',
imageInputSize: '输入尺寸',
imageOutputSize: '输出尺寸',
imageInputTokens: '图片输入 Token',
imageInputTokenPrice: '图片输入单价',
imageInputCost: '图片输入费用',
imageOutputTokens: '图片输出 Token',
imageOutputTokenPrice: '图片输出单价',
imageOutputCost: '图片输出费用',
@@ -504,6 +507,7 @@ export default {
outputPrice: '输出',
cacheWritePrice: '缓存写入',
cacheReadPrice: '缓存读取',
imageInputPrice: '图片输入',
imageOutputPrice: '图片输出',
perRequestPrice: '每次请求',
intervals: '阶梯定价',
+2
View File
@@ -1398,6 +1398,8 @@ export interface UsageLog {
image_output_size: string | null
image_size_source: ImageSizeSource | null
image_size_breakdown: ImageSizeBreakdown | null
image_input_tokens: number
image_input_cost: number
image_output_tokens: number
image_output_cost: number
+20
View File
@@ -22,6 +22,26 @@ export const textOutputTokens = (row: ImageOutputTokenRow | null | undefined): n
export const hasImageOutputCost = (row: ImageOutputCostRow | null | undefined): boolean =>
(row?.image_output_cost ?? 0) > 0
// --- Image input token / cost helpers ---
type ImageInputTokenRow = Pick<UsageLog, 'input_tokens' | 'image_input_tokens'>
type ImageInputCostRow = Pick<UsageLog, 'image_input_cost'>
/** Whether the row contains any image-input tokens (e.g. gpt-image-2 image edits). */
export const hasImageInputTokens = (row: ImageInputTokenRow | null | undefined): boolean =>
(row?.image_input_tokens ?? 0) > 0
/**
* Text-only input tokens (total input minus image-input).
* Returns 0 when no text tokens exist.
*/
export const textInputTokens = (row: ImageInputTokenRow | null | undefined): number =>
Math.max(0, (row?.input_tokens ?? 0) - (row?.image_input_tokens ?? 0))
/** Whether the row has a non-zero image-input cost. */
export const hasImageInputCost = (row: ImageInputCostRow | null | undefined): boolean =>
(row?.image_input_cost ?? 0) > 0
// --- Image size / billing helpers ---
const knownImageSizeSources = new Set(['output', 'input', 'default', 'legacy'])
@@ -855,6 +855,7 @@ function addPricingEntry(sectionIdx: number) {
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
@@ -887,6 +888,7 @@ async function syncLatestModels(sectionIdx: number) {
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
@@ -951,6 +953,7 @@ function addRulePricingEntry(sectionIdx: number, ruleIndex: number) {
output_price: null,
cache_write_price: null,
cache_read_price: null,
image_input_price: null,
image_output_price: null,
per_request_price: null,
intervals: []
@@ -1066,6 +1069,7 @@ function accountStatsRulesToAPI(): AccountStatsPricingRule[] {
output_price: mTokToPerToken(p.output_price),
cache_write_price: mTokToPerToken(p.cache_write_price),
cache_read_price: mTokToPerToken(p.cache_read_price),
image_input_price: mTokToPerToken(p.image_input_price),
image_output_price: mTokToPerToken(p.image_output_price),
per_request_price: p.per_request_price != null && p.per_request_price !== '' ? Number(p.per_request_price) : null,
intervals: formIntervalsToAPI(p.intervals || [])
@@ -1106,6 +1110,7 @@ function formToAPI(): { group_ids: number[], model_pricing: ChannelModelPricing[
output_price: mTokToPerToken(entry.output_price),
cache_write_price: mTokToPerToken(entry.cache_write_price),
cache_read_price: mTokToPerToken(entry.cache_read_price),
image_input_price: mTokToPerToken(entry.image_input_price),
image_output_price: mTokToPerToken(entry.image_output_price),
per_request_price: entry.per_request_price != null && entry.per_request_price !== '' ? Number(entry.per_request_price) : null,
intervals: formIntervalsToAPI(entry.intervals || [])
@@ -1194,6 +1199,7 @@ function apiToForm(channel: Channel): PlatformSection[] {
output_price: perTokenToMTok(p.output_price),
cache_write_price: perTokenToMTok(p.cache_write_price),
cache_read_price: perTokenToMTok(p.cache_read_price),
image_input_price: perTokenToMTok(p.image_input_price),
image_output_price: perTokenToMTok(p.image_output_price),
per_request_price: p.per_request_price,
intervals: apiIntervalsToForm(p.intervals || [])
@@ -1382,6 +1388,7 @@ function distributeRulesToPlatforms(apiRules: AccountStatsPricingRule[]) {
output_price: perTokenToMTok(p.output_price),
cache_write_price: perTokenToMTok(p.cache_write_price),
cache_read_price: perTokenToMTok(p.cache_read_price),
image_input_price: perTokenToMTok(p.image_input_price),
image_output_price: perTokenToMTok(p.image_output_price),
per_request_price: p.per_request_price,
intervals: apiIntervalsToForm(p.intervals || [])