mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 16:20:27 +00:00
Merge pull request #4396 from StarryKira/fix/4386-image-input-pricing
fix(billing): 图像输入 token 按独立单价计费(gpt-image-2 图片编辑)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '阶梯定价',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
Reference in New Issue
Block a user