mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-31 08:25:35 +00:00
图片编辑/图生图请求的图片输入 token 此前并入 input_tokens/input_cost, 无法对账。拆分上报口径,total_cost 保持不变。 后端: - CostBreakdown 新增 ImageInputCost;computeTokenBreakdown 将图片输入费用 从 InputCost 拆出(InputCost 从此仅含文本输入),并纳入 tier 倍率与总额; 长上下文合并路径同步携带 ImageInputCost - 迁移 179:usage_logs 新增 image_input_tokens / image_input_cost 列 - UsageLog、insert/query 仓储(含定位参数数组、CTE 列表、扫描顺序)、 DTO 与 mapper 补齐两列 - openai_gateway_usage 从 usage 与 cost 落库图片输入 token/费用 前端: - UsageLog 类型、imageUsage 工具(hasImageInputTokens/Cost、textInputTokens) - 用量表 token 徽标、Token/费用 tooltip 与单价行按图/文输入拆分展示 - zh/en usage.* i18n 测试: - 新增 gpt-image-2 图片编辑复现用例(复现 #4386 的 $0.016081 期望值) - 新增 usage 提取器图片输入 token 解析用例(input_tokens_details.image_tokens) - 更新 doubao 图文分价用例与仓储/契约测试以匹配新的 input/image 拆分口径 相关 #4386。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015wcJTKDddxXSQrepSs3wrU
99 lines
3.6 KiB
TypeScript
99 lines
3.6 KiB
TypeScript
import type { UsageLog } from '@/types'
|
|
|
|
type Translate = (key: string) => string
|
|
|
|
// --- Image output token / cost helpers ---
|
|
|
|
type ImageOutputTokenRow = Pick<UsageLog, 'output_tokens' | 'image_output_tokens'>
|
|
type ImageOutputCostRow = Pick<UsageLog, 'image_output_cost'>
|
|
|
|
/** Whether the row contains any image-output tokens. */
|
|
export const hasImageOutputTokens = (row: ImageOutputTokenRow | null | undefined): boolean =>
|
|
(row?.image_output_tokens ?? 0) > 0
|
|
|
|
/**
|
|
* Text-only output tokens (total output minus image-output).
|
|
* Returns 0 when no text tokens exist.
|
|
*/
|
|
export const textOutputTokens = (row: ImageOutputTokenRow | null | undefined): number =>
|
|
Math.max(0, (row?.output_tokens ?? 0) - (row?.image_output_tokens ?? 0))
|
|
|
|
/** Whether the row has a non-zero image-output cost. */
|
|
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'])
|
|
const knownImageBillingSizes = new Set(['1K', '2K', '4K', 'mixed'])
|
|
|
|
type ImageUsageRow = Pick<
|
|
UsageLog,
|
|
'image_size' | 'image_input_size' | 'image_output_size' | 'image_size_source' | 'image_size_breakdown'
|
|
>
|
|
|
|
const trimmed = (value: string | null | undefined): string => value?.trim() ?? ''
|
|
|
|
export const formatImageBillingSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
|
|
const size = trimmed(row?.image_size)
|
|
if (!size) {
|
|
return t('usage.imageSizeNotRecorded')
|
|
}
|
|
if (knownImageBillingSizes.has(size)) {
|
|
return size
|
|
}
|
|
return `${t('usage.imageSizeLegacyUnstandardized')}: ${size}`
|
|
}
|
|
|
|
export const formatImageInputSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
|
|
const size = trimmed(row?.image_input_size)
|
|
return size || t('usage.imageSizeUnknown')
|
|
}
|
|
|
|
export const formatImageOutputSize = (row: ImageUsageRow | null | undefined, t: Translate): string => {
|
|
const size = trimmed(row?.image_output_size)
|
|
return size || t('usage.imageSizeUnknown')
|
|
}
|
|
|
|
export const formatImageSizeSource = (row: ImageUsageRow | null | undefined, t: Translate): string => {
|
|
const source = trimmed(row?.image_size_source).toLowerCase()
|
|
if (knownImageSizeSources.has(source)) {
|
|
return t(`usage.imageSizeSource${source.charAt(0).toUpperCase()}${source.slice(1)}`)
|
|
}
|
|
if (trimmed(row?.image_size)) {
|
|
return t('usage.imageSizeSourceLegacy')
|
|
}
|
|
return t('usage.imageSizeSourceMissing')
|
|
}
|
|
|
|
export const formatImageSizeBreakdown = (row: ImageUsageRow | null | undefined): string => {
|
|
const breakdown = row?.image_size_breakdown
|
|
if (!breakdown || Object.keys(breakdown).length === 0) {
|
|
return ''
|
|
}
|
|
return ['1K', '2K', '4K']
|
|
.filter((tier) => (breakdown[tier] ?? 0) > 0)
|
|
.map((tier) => `${tier} x ${breakdown[tier]}`)
|
|
.join(', ')
|
|
}
|