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
@@ -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'))"