from __future__ import annotations from dataclasses import dataclass from decimal import Decimal, ROUND_HALF_UP from typing import Any, Mapping from app.enums.model_pricing import ( ModelPricingBillingMode, ModelPricingCalculatorVersion, PricingBillBy, ) from app.services.model_pricing.usage_normalizer import safe_bool, safe_int MILLION = Decimal("1000000") MONEY_QUANT = Decimal("0.00000001") class PricingCalculationError(ValueError): pass @dataclass(slots=True) class PricingCalculationResult: amount: Decimal currency: str is_estimated: bool selected_rate: Decimal | None breakdown: dict[str, Any] usage_source: str def to_decimal(value: Any, default: str = "0") -> Decimal: try: if value in (None, ""): return Decimal(default) return Decimal(str(value)) except Exception: return Decimal(default) def money(value: Decimal) -> Decimal: return value.quantize(MONEY_QUANT, rounding=ROUND_HALF_UP) def _select_text_tier(rule_json: Mapping[str, Any], context_tokens: int) -> Mapping[str, Any]: for tier in rule_json.get("tiers") or []: maximum = tier.get("max_context_tokens") if maximum is None or context_tokens <= safe_int(maximum): return tier raise PricingCalculationError(f"没有匹配到文本 Token 档位: context_tokens={context_tokens}") def _calculate_text(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult: input_tokens = max(0, safe_int(usage.get("input_tokens"))) output_tokens = max(0, safe_int(usage.get("output_tokens"))) context_tokens = max(0, safe_int(usage.get("context_tokens"), input_tokens)) cached_input = max(0, min(input_tokens, safe_int(usage.get("cached_input_tokens")))) audio_input = max(0, min(input_tokens, safe_int(usage.get("audio_input_tokens")))) cached_audio = max(0, min(audio_input, safe_int(usage.get("cached_audio_input_tokens")))) tier = _select_text_tier(rule_json, context_tokens) cached_text_input = max(0, cached_input - cached_audio) normal_audio_input = max(0, audio_input - cached_audio) # cached_input_tokens may include cached audio tokens. Add cached_audio back once # so the four buckets always sum exactly to input_tokens. normal_text_input = max(0, input_tokens - audio_input - cached_text_input) input_rate = to_decimal(tier.get("input_rate")) output_rate = to_decimal(tier.get("output_rate")) cached_rate = to_decimal(tier.get("cached_input_rate"), str(input_rate)) audio_rate = to_decimal(tier.get("audio_input_rate"), str(input_rate)) cached_audio_rate = to_decimal(tier.get("cached_audio_input_rate"), str(cached_rate)) normal_input_cost = to_decimal(normal_text_input) * input_rate / MILLION cached_input_cost = to_decimal(cached_text_input) * cached_rate / MILLION audio_input_cost = to_decimal(normal_audio_input) * audio_rate / MILLION cached_audio_cost = to_decimal(cached_audio) * cached_audio_rate / MILLION output_cost = to_decimal(output_tokens) * output_rate / MILLION cache_storage_tokens = max(0, safe_int(usage.get("cache_storage_tokens"))) cache_storage_hours = max(Decimal("0"), to_decimal(usage.get("cache_storage_duration_hours"))) storage_rate = to_decimal(rule_json.get("cache_storage_rate_per_million_token_hour")) cache_storage_cost = to_decimal(cache_storage_tokens) * cache_storage_hours * storage_rate / MILLION total = money( normal_input_cost + cached_input_cost + audio_input_cost + cached_audio_cost + output_cost + cache_storage_cost ) return PricingCalculationResult( amount=total, currency=currency, is_estimated=False, selected_rate=None, usage_source=str(usage.get("usage_source") or "provider"), breakdown={ "formula": "token_items * corresponding_rate / 1e6", "context_tokens": context_tokens, "selected_tier": dict(tier), "normal_text_input_tokens": normal_text_input, "cached_text_input_tokens": cached_text_input, "normal_audio_input_tokens": normal_audio_input, "cached_audio_input_tokens": cached_audio, "output_tokens": output_tokens, "cache_storage_tokens": cache_storage_tokens, "cache_storage_duration_hours": str(cache_storage_hours), "normal_input_cost": str(money(normal_input_cost)), "cached_input_cost": str(money(cached_input_cost)), "audio_input_cost": str(money(audio_input_cost)), "cached_audio_input_cost": str(money(cached_audio_cost)), "output_cost": str(money(output_cost)), "cache_storage_cost": str(money(cache_storage_cost)), "total_cost": str(total), }, ) def _resolve_billable_output_count(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, str, bool]: bill_by = str(rule_json.get("bill_by") or PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value) if bill_by == PricingBillBy.REQUESTED_OUTPUT_COUNT.value: return max(0, safe_int(usage.get("requested_output_count"))), bill_by, True if bill_by == PricingBillBy.PROVIDER_BILLED_COUNT.value: count = max(0, safe_int(usage.get("provider_billed_count"))) return count, bill_by, count <= 0 if bill_by != PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value: raise PricingCalculationError(f"不支持的图片计费数量来源: {bill_by}") return max(0, safe_int(usage.get("successful_output_count"), safe_int(usage.get("generated_image_count")))), bill_by, False def _calculate_image_per_output(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult: count, bill_by, count_estimated = _resolve_billable_output_count(rule_json, usage) if count <= 0: raise PricingCalculationError("图片计价缺少有效输出数量") rate = to_decimal(rule_json.get("output_rate")) total = money(to_decimal(count) * rate) return PricingCalculationResult( amount=total, currency=currency, is_estimated=count_estimated or safe_bool(usage.get("output_count_is_estimated")), selected_rate=rate, usage_source=str(usage.get("usage_source") or "provider_response"), breakdown={ "formula": "billable_output_count * output_rate", "bill_by": bill_by, "billable_output_count": count, "output_rate": str(rate), "total_cost": str(total), }, ) def _select_image_tier(output_tiers: list[Mapping[str, Any]], pixels: int) -> Mapping[str, Any]: for tier in output_tiers: maximum = tier.get("max_pixels") if maximum is None or pixels <= safe_int(maximum): return tier raise PricingCalculationError(f"没有匹配到图片输出像素档位: pixels={pixels}") def _calculate_image_tiered(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult: input_count = max(0, safe_int(usage.get("provider_input_image_count"), safe_int(usage.get("input_image_count")))) free_count = max(0, safe_int(rule_json.get("free_input_images"))) billable_input_count = max(0, input_count - free_count) input_rate = to_decimal(rule_json.get("input_image_rate")) input_cost = to_decimal(billable_input_count) * input_rate output_items = usage.get("output_items") or [] if not isinstance(output_items, list): output_items = [] count, bill_by, count_estimated = _resolve_billable_output_count(rule_json, usage) if count <= 0: raise PricingCalculationError("图片计价缺少有效输出数量") output_tiers = list(rule_json.get("output_tiers") or []) output_cost = Decimal("0") item_breakdown: list[dict[str, Any]] = [] pixels_estimated = False if output_items: priced_count = 0 for index, item in enumerate(output_items[:count]): if not isinstance(item, Mapping): continue pixels = max(0, safe_int(item.get("pixels"))) if pixels <= 0: width = max(0, safe_int(item.get("width"))) height = max(0, safe_int(item.get("height"))) pixels = width * height if pixels <= 0: raise PricingCalculationError(f"第 {index + 1} 张输出图片缺少明确像素") tier = _select_image_tier(output_tiers, pixels) rate = to_decimal(tier.get("rate")) output_cost += rate priced_count += 1 item_breakdown.append({"index": index, "pixels": pixels, "tier": dict(tier), "rate": str(rate)}) # provider_billed_count/requested_output_count may be greater than the returned # output item array. Only use an explicit fallback size; never silently under-bill. remaining = count - priced_count if remaining > 0: fallback_pixels = max(0, safe_int(usage.get("output_pixels"))) if fallback_pixels <= 0: fallback_width = max(0, safe_int(usage.get("output_width"))) fallback_height = max(0, safe_int(usage.get("output_height"))) fallback_pixels = fallback_width * fallback_height if fallback_pixels <= 0: raise PricingCalculationError(f"仍有 {remaining} 张计费输出缺少明确像素") tier = _select_image_tier(output_tiers, fallback_pixels) rate = to_decimal(tier.get("rate")) output_cost += to_decimal(remaining) * rate pixels_estimated = True item_breakdown.append( { "count": remaining, "pixels": fallback_pixels, "tier": dict(tier), "rate": str(rate), "size_source": "explicit_fallback", } ) else: pixels = max(0, safe_int(usage.get("output_pixels"))) if pixels <= 0: width = max(0, safe_int(usage.get("output_width"))) height = max(0, safe_int(usage.get("output_height"))) pixels = width * height if pixels <= 0: raise PricingCalculationError("图片输出缺少明确像素") tier = _select_image_tier(output_tiers, pixels) rate = to_decimal(tier.get("rate")) output_cost = to_decimal(count) * rate pixels_estimated = True item_breakdown = [{"count": count, "pixels": pixels, "tier": dict(tier), "rate": str(rate)}] total = money(input_cost + output_cost) return PricingCalculationResult( amount=total, currency=currency, is_estimated=count_estimated or pixels_estimated or safe_bool(usage.get("output_pixels_is_estimated")), selected_rate=None, usage_source=str(usage.get("usage_source") or "provider_response"), breakdown={ "formula": "billable_input_count*input_image_rate + sum(output_item_rate)", "bill_by": bill_by, "provider_input_image_count": input_count, "free_input_images": free_count, "billable_input_image_count": billable_input_count, "input_image_rate": str(input_rate), "input_cost": str(money(input_cost)), "billable_output_count": count, "output_items": item_breakdown, "output_cost": str(money(output_cost)), "total_cost": str(total), }, ) def _normalize_resolution(value: Any) -> str: resolution = str(value or "").lower().strip().replace(" ", "") return {"2160p": "4k", "uhd": "4k"}.get(resolution, resolution) def _normalize_ratio(value: Any) -> str: return str(value or "").strip().replace(":", ":") def _resolve_video_dimensions(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, int, str]: width = max(0, safe_int(usage.get("output_width"))) height = max(0, safe_int(usage.get("output_height"))) if width > 0 and height > 0: return width, height, str(usage.get("dimension_source") or "provider_response") resolution = _normalize_resolution(usage.get("resolution")) ratio = _normalize_ratio(usage.get("aspect_ratio")) dimension_map = rule_json.get("dimension_map") or {} resolution_map = dimension_map.get(resolution) if isinstance(dimension_map, Mapping) else None value = resolution_map.get(ratio) if isinstance(resolution_map, Mapping) else None if isinstance(value, Mapping): width = max(0, safe_int(value.get("width"))) height = max(0, safe_int(value.get("height"))) elif isinstance(value, (list, tuple)) and len(value) >= 2: width, height = max(0, safe_int(value[0])), max(0, safe_int(value[1])) if width <= 0 or height <= 0: raise PricingCalculationError(f"视频缺少明确尺寸,且价格规则未配置 dimension_map: resolution={resolution}, ratio={ratio}") return width, height, "pricing_rule_map" def _rate_specificity(row: Mapping[str, Any]) -> tuple[int, int, int]: resolutions = {_normalize_resolution(v) for v in (row.get("resolutions") or [])} modes = {str(v).lower() for v in (row.get("inference_modes") or [])} constrained = int(bool(resolutions)) + int(row.get("has_input_video") is not None) + int( row.get("generate_audio") is not None ) + int(bool(modes)) # More constrained dimensions win; within a dimension, a smaller allowed set is # more specific. Empty sets represent wildcard and therefore score lowest. return constrained, -(len(resolutions) if resolutions else 10_000), -(len(modes) if modes else 10_000) def _match_video_rate(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> Mapping[str, Any]: resolution = _normalize_resolution(usage.get("resolution")) has_input_video = safe_bool(usage.get("has_input_video")) generate_audio = safe_bool(usage.get("generate_audio")) inference_mode = str(usage.get("inference_mode") or "online").lower().strip() matched: list[Mapping[str, Any]] = [] for row in rule_json.get("rates") or []: resolutions = [_normalize_resolution(v) for v in (row.get("resolutions") or [])] if resolutions and resolution not in resolutions: continue if row.get("has_input_video") is not None and safe_bool(row.get("has_input_video")) != has_input_video: continue if row.get("generate_audio") is not None and safe_bool(row.get("generate_audio")) != generate_audio: continue modes = [str(v).lower() for v in (row.get("inference_modes") or [])] if modes and inference_mode not in modes: continue matched.append(row) if not matched: raise PricingCalculationError( "没有匹配到视频价格档位: " f"resolution={resolution}, has_input_video={has_input_video}, " f"generate_audio={generate_audio}, inference_mode={inference_mode}" ) matched.sort(key=_rate_specificity, reverse=True) if len(matched) > 1 and _rate_specificity(matched[0]) == _rate_specificity(matched[1]): raise PricingCalculationError("视频价格档位存在同等优先级重叠,请修正规则") return matched[0] def calculate_video_formula_tokens(rule_json: Mapping[str, Any], usage: Mapping[str, Any]) -> tuple[int, dict[str, Any]]: input_seconds = max(Decimal("0"), to_decimal(usage.get("input_video_duration_seconds"))) output_seconds = max(Decimal("0"), to_decimal(usage.get("output_video_duration_seconds"))) fps = max(Decimal("0"), to_decimal(usage.get("fps"))) if fps <= 0: fps = max(Decimal("0"), to_decimal(rule_json.get("default_fps"))) width, height, dimension_source = _resolve_video_dimensions(rule_json, usage) if output_seconds <= 0 or fps <= 0: raise PricingCalculationError("视频公式估算缺少输出时长或 FPS") if safe_bool(usage.get("has_input_video")) and input_seconds <= 0: raise PricingCalculationError("视频包含输入视频,但缺少输入视频时长,禁止估算") tokens = (input_seconds + output_seconds) * Decimal(width) * Decimal(height) * fps / Decimal("1024") rounded = max(0, int(tokens.quantize(Decimal("1"), rounding=ROUND_HALF_UP))) return rounded, { "input_video_duration_seconds": str(input_seconds), "output_video_duration_seconds": str(output_seconds), "output_width": width, "output_height": height, "fps": str(fps), "dimension_source": dimension_source, } def _calculate_video(rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str) -> PricingCalculationResult: actual_tokens = max(0, safe_int(usage.get("total_tokens"))) formula_detail: dict[str, Any] = {} if actual_tokens > 0: total_tokens = actual_tokens use_formula = False else: total_tokens, formula_detail = calculate_video_formula_tokens(rule_json, usage) use_formula = True rate_row = _match_video_rate(rule_json, usage) rate = to_decimal(rate_row.get("rate")) total = money(to_decimal(total_tokens) * rate / MILLION) return PricingCalculationResult( amount=total, currency=currency, is_estimated=use_formula, selected_rate=rate, usage_source="request_formula" if use_formula else str(usage.get("usage_source") or "provider"), breakdown={ "formula": "billable_total_tokens * rate / 1e6", "token_source": "request_formula" if use_formula else "provider", "provider_total_tokens": actual_tokens, "billable_total_tokens": total_tokens, "formula_parameters": formula_detail or None, "selected_rate_rule": dict(rate_row), "rate": str(rate), "total_cost": str(total), }, ) def _expected_calculator(billing_mode: str) -> str: mapping = { ModelPricingBillingMode.TEXT_TOKEN_TIERED.value: ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value, ModelPricingBillingMode.IMAGE_PER_OUTPUT.value: ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value, ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value: ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value, ModelPricingBillingMode.VIDEO_TOKEN_RATE.value: ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value, } value = mapping.get(billing_mode) if not value: raise PricingCalculationError(f"不支持的计价模式: {billing_mode}") return value def _constraint_set(values: Any, *, normalize) -> set[str] | None: normalized = {normalize(value) for value in (values or []) if str(value or "").strip()} return normalized or None def _constraints_overlap(left: Mapping[str, Any], right: Mapping[str, Any]) -> bool: left_res = _constraint_set(left.get("resolutions"), normalize=_normalize_resolution) right_res = _constraint_set(right.get("resolutions"), normalize=_normalize_resolution) if left_res is not None and right_res is not None and left_res.isdisjoint(right_res): return False for key in ("has_input_video", "generate_audio"): lv, rv = left.get(key), right.get(key) if lv is not None and rv is not None and safe_bool(lv) != safe_bool(rv): return False left_modes = _constraint_set(left.get("inference_modes"), normalize=lambda value: str(value).lower()) right_modes = _constraint_set(right.get("inference_modes"), normalize=lambda value: str(value).lower()) if left_modes is not None and right_modes is not None and left_modes.isdisjoint(right_modes): return False return True def _constraint_subset(child: Mapping[str, Any], parent: Mapping[str, Any]) -> bool: child_res = _constraint_set(child.get("resolutions"), normalize=_normalize_resolution) parent_res = _constraint_set(parent.get("resolutions"), normalize=_normalize_resolution) if parent_res is not None and (child_res is None or not child_res.issubset(parent_res)): return False for key in ("has_input_video", "generate_audio"): child_value, parent_value = child.get(key), parent.get(key) if parent_value is not None and (child_value is None or safe_bool(child_value) != safe_bool(parent_value)): return False child_modes = _constraint_set(child.get("inference_modes"), normalize=lambda value: str(value).lower()) parent_modes = _constraint_set(parent.get("inference_modes"), normalize=lambda value: str(value).lower()) if parent_modes is not None and (child_modes is None or not child_modes.issubset(parent_modes)): return False return True def _validate_video_rate_overlaps(rates: list[Mapping[str, Any]]) -> None: for left_index, left in enumerate(rates): for right_index in range(left_index + 1, len(rates)): right = rates[right_index] if not _constraints_overlap(left, right): continue left_subset_right = _constraint_subset(left, right) right_subset_left = _constraint_subset(right, left) if left_subset_right and right_subset_left: raise PricingCalculationError("视频价格档位存在重复条件") if not left_subset_right and not right_subset_left: raise PricingCalculationError("视频价格档位存在交叉重叠,无法确定唯一价格") # The more specific row must be before its fallback row, matching the UI # and keeping exported rule JSON human-readable and deterministic. if right_subset_left: raise PricingCalculationError("视频价格档位顺序错误:具体条件必须放在通用兜底条件之前") def validate_pricing_rule(*, billing_mode: str, calculator_version: str, rule_json: Mapping[str, Any]) -> None: if calculator_version != _expected_calculator(billing_mode): raise PricingCalculationError(f"计价模式与计算器版本不匹配: {billing_mode}/{calculator_version}") if billing_mode == ModelPricingBillingMode.TEXT_TOKEN_TIERED.value: tiers = list(rule_json.get("tiers") or []) if not tiers: raise PricingCalculationError("文本计价至少需要一个 Token 档位") previous_max = 0 for index, tier in enumerate(tiers, start=1): maximum = tier.get("max_context_tokens") if maximum is None and index != len(tiers): raise PricingCalculationError("无上限 Token 档位只能放在最后") if maximum is not None: maximum_int = safe_int(maximum) if maximum_int <= previous_max: raise PricingCalculationError("Token 档位上限必须严格递增") previous_max = maximum_int for key in ("input_rate", "output_rate"): if to_decimal(tier.get(key), "-1") < 0: raise PricingCalculationError(f"{key} 不能为空且不能小于 0") return if billing_mode == ModelPricingBillingMode.IMAGE_PER_OUTPUT.value: if to_decimal(rule_json.get("output_rate"), "-1") < 0: raise PricingCalculationError("图片输出单价不能为空且不能小于 0") if str(rule_json.get("bill_by") or PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value) not in {v.value for v in PricingBillBy}: raise PricingCalculationError("bill_by 不受支持") return if billing_mode == ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value: if safe_int(rule_json.get("free_input_images")) < 0: raise PricingCalculationError("免费输入图片数不能小于 0") if to_decimal(rule_json.get("input_image_rate"), "-1") < 0: raise PricingCalculationError("输入图片单价不能为空且不能小于 0") tiers = list(rule_json.get("output_tiers") or []) if not tiers: raise PricingCalculationError("图片输出至少需要一个像素档位") previous_max = 0 for index, tier in enumerate(tiers, start=1): maximum = tier.get("max_pixels") if maximum is None and index != len(tiers): raise PricingCalculationError("无上限像素档位只能放在最后") if maximum is not None: maximum_int = safe_int(maximum) if maximum_int <= previous_max: raise PricingCalculationError("图片像素档位上限必须严格递增") previous_max = maximum_int if to_decimal(tier.get("rate"), "-1") < 0: raise PricingCalculationError("图片输出单价不能为空且不能小于 0") return if billing_mode == ModelPricingBillingMode.VIDEO_TOKEN_RATE.value: rates = list(rule_json.get("rates") or []) if not rates: raise PricingCalculationError("视频计价至少需要一个价格档位") signatures: set[tuple[Any, ...]] = set() for row in rates: if to_decimal(row.get("rate"), "-1") < 0: raise PricingCalculationError("视频 Token 单价不能为空且不能小于 0") signature = ( tuple(sorted(_normalize_resolution(v) for v in (row.get("resolutions") or []))), row.get("has_input_video"), row.get("generate_audio"), tuple(sorted(str(v).lower() for v in (row.get("inference_modes") or []))), ) if signature in signatures: raise PricingCalculationError("视频价格档位存在重复条件") signatures.add(signature) _validate_video_rate_overlaps(rates) dimension_map = rule_json.get("dimension_map") or {} if dimension_map and not isinstance(dimension_map, Mapping): raise PricingCalculationError("dimension_map 必须是对象") return raise PricingCalculationError(f"不支持的计价模式: {billing_mode}") def calculate_pricing( *, billing_mode: str, calculator_version: str, rule_json: Mapping[str, Any], usage: Mapping[str, Any], currency: str = "CNY", ) -> PricingCalculationResult: validate_pricing_rule( billing_mode=billing_mode, calculator_version=calculator_version, rule_json=rule_json, ) if calculator_version == ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value: return _calculate_text(rule_json, usage, currency) if calculator_version == ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value: return _calculate_image_per_output(rule_json, usage, currency) if calculator_version == ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value: return _calculate_image_tiered(rule_json, usage, currency) if calculator_version == ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value: return _calculate_video(rule_json, usage, currency) raise PricingCalculationError(f"不支持的计算器版本: {calculator_version}")