This commit is contained in:
2026-07-10 17:09:14 +08:00
parent ad2e40bc67
commit d07cd508ee
54 changed files with 7111 additions and 619 deletions
@@ -0,0 +1,7 @@
"""模型计价服务软包。
各调用方显式导入 calculator/rule_service/snapshot_service,避免导入纯计算器时
提前初始化数据库引擎,降低模块耦合并便于离线测试。
"""
__all__: list[str] = []
@@ -0,0 +1,323 @@
from __future__ import annotations
import hashlib
import json
import re
from decimal import Decimal
from typing import Any, Iterable, Mapping
from urllib.parse import urlsplit, urlunsplit
from app.services.model_pricing.usage_normalizer import (
extract_image_output_items,
parse_size,
safe_bool,
safe_float,
safe_int,
safe_json_dict,
sanitize_output_items,
)
MEDIA_TYPES = {"image", "video", "audio"}
def _safe_json(value: Any) -> Any:
if value in (None, ""):
return None
if isinstance(value, (dict, list)):
return value
if isinstance(value, str):
try:
return json.loads(value)
except Exception:
return value
return value
def _normalize_url(value: str | None) -> tuple[str | None, str | None]:
if not value:
return None, None
text = str(value).strip()
try:
parts = urlsplit(text)
if parts.scheme and parts.netloc:
normalized = urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", ""))
else:
normalized = text.split("?", 1)[0].split("#", 1)[0]
except Exception:
normalized = text.split("?", 1)[0].split("#", 1)[0]
normalized = normalized[:1024]
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
return normalized, digest
def _guess_media_type(item: Mapping[str, Any]) -> str | None:
value = str(item.get("type") or item.get("media_type") or item.get("resource_type") or "").lower().strip()
if value in MEDIA_TYPES:
return value
url = str(item.get("url") or item.get("path") or item.get("display_url") or "").lower()
if re.search(r"\.(png|jpe?g|webp|gif|bmp)(?:\?|$)", url):
return "image"
if re.search(r"\.(mp4|mov|m4v|webm|avi|mkv)(?:\?|$)", url):
return "video"
if re.search(r"\.(mp3|wav|aac|m4a|flac|ogg)(?:\?|$)", url):
return "audio"
return None
def _walk_reference_items(value: Any) -> Iterable[Mapping[str, Any]]:
parsed = _safe_json(value)
if isinstance(parsed, list):
for item in parsed:
yield from _walk_reference_items(item)
return
if isinstance(parsed, dict):
if _guess_media_type(parsed) or any(k in parsed for k in ("url", "path", "resource_id", "private_asset_id")):
yield parsed
return
for child in parsed.values():
if isinstance(child, (dict, list, str)):
yield from _walk_reference_items(child)
def _billable_input(raw: Mapping[str, Any], media_type: str) -> bool:
if "billable_input" in raw:
return safe_bool(raw.get("billable_input"), True)
role = str(raw.get("role") or raw.get("label") or raw.get("reference_role") or "").lower()
if role in {"cover", "preview", "display_only", "generated_output", "output"}:
return False
return media_type in MEDIA_TYPES
def build_attachment_snapshot(media_references: Any) -> tuple[dict[str, Any], dict[str, Any]]:
items: list[dict[str, Any]] = []
image_count = video_count = audio_count = 0
provider_input_image_count = 0
provider_input_video_count = 0
provider_input_audio_count = 0
video_duration = Decimal("0")
audio_duration = Decimal("0")
seen: set[str] = set()
for raw in _walk_reference_items(media_references):
media_type = _guess_media_type(raw)
if media_type not in MEDIA_TYPES:
continue
raw_url = raw.get("url") or raw.get("path") or raw.get("display_url") or raw.get("preview_url")
safe_url, url_hash = _normalize_url(str(raw_url) if raw_url else None)
identity = str(
raw.get("resource_id")
or raw.get("upload_resource_id")
or raw.get("private_asset_id")
or url_hash
or f"{media_type}:{len(items)}"
)
dedupe_key = f"{media_type}:{identity}"
if dedupe_key in seen:
continue
seen.add(dedupe_key)
duration = max(0.0, safe_float(raw.get("duration"), safe_float(raw.get("duration_seconds"))))
billable = _billable_input(raw, media_type)
item = {
"type": media_type,
"role": raw.get("role") or raw.get("label") or raw.get("reference_role"),
"source": raw.get("source"),
"billable_input": billable,
"resource_id": raw.get("resource_id") or raw.get("upload_resource_id"),
"private_asset_id": raw.get("private_asset_id"),
"name": raw.get("name") or raw.get("filename"),
"duration_seconds": duration or None,
"file_size": safe_int(raw.get("file_size"), safe_int(raw.get("size"))) or None,
"safe_url": safe_url,
"url_sha256": url_hash,
}
items.append({k: v for k, v in item.items() if v is not None})
if media_type == "image":
image_count += 1
provider_input_image_count += int(billable)
elif media_type == "video":
video_count += 1
provider_input_video_count += int(billable)
video_duration += Decimal(str(duration))
else:
audio_count += 1
provider_input_audio_count += int(billable)
audio_duration += Decimal(str(duration))
counts = {
"attachment_image_count": image_count,
"attachment_video_count": video_count,
"attachment_audio_count": audio_count,
"attachment_total_count": image_count + video_count + audio_count,
"attachment_video_duration_seconds": video_duration,
"attachment_audio_duration_seconds": audio_duration,
"provider_input_image_count": provider_input_image_count,
"provider_input_video_count": provider_input_video_count,
"provider_input_audio_count": provider_input_audio_count,
}
snapshot = {
"schema_version": 1,
"items": items,
"counts": {
"image": image_count,
"video": video_count,
"audio": audio_count,
"total": image_count + video_count + audio_count,
"provider_input_image": provider_input_image_count,
"provider_input_video": provider_input_video_count,
"provider_input_audio": provider_input_audio_count,
},
"durations": {
"video_seconds": str(video_duration),
"audio_seconds": str(audio_duration),
},
}
return snapshot, counts
def parse_dimensions(*values: Any, resolution: str | None = None, aspect_ratio: str | None = None) -> tuple[int, int]:
"""仅解析明确像素;resolution/aspect_ratio 不再映射为猜测尺寸。"""
del resolution, aspect_ratio
for value in values:
width, height = parse_size(value)
if width > 0 and height > 0:
return width, height
return 0, 0
def _engine_snapshot(owner: Any) -> dict[str, Any]:
return safe_json_dict(getattr(owner, "engine_snapshot_json", None))
def build_generation_snapshot(
owner: Any,
*,
provider_response: Any = None,
stage: str | None = None,
) -> tuple[dict[str, Any], dict[str, int], dict[str, Any]]:
"""构建请求/Provider/资源快照。
图片只读取同步接口明确的 data 输出条目;不会递归扫描 provider response 中的通用 URL。
"""
gen_type = str(getattr(owner, "gen_type", None) or getattr(owner, "media_type", None) or "").lower().strip()
response = safe_json_dict(provider_response if provider_response is not None else getattr(owner, "provider_response_json", None))
engine_snapshot = _engine_snapshot(owner)
aspect_ratio = getattr(owner, "aspect_ratio", None) or getattr(owner, "image_proportion", None)
resolution = getattr(owner, "resolution", None)
width, height = parse_dimensions(
response.get("size"),
getattr(owner, "image_px", None),
engine_snapshot.get("selected_px"),
engine_snapshot.get("image_px"),
)
dimension_source = "unavailable"
if parse_dimensions(response.get("size")) != (0, 0):
dimension_source = "provider_response"
elif parse_dimensions(getattr(owner, "image_px", None)) != (0, 0):
dimension_source = "request_explicit"
elif parse_dimensions(engine_snapshot.get("selected_px"), engine_snapshot.get("image_px")) != (0, 0):
dimension_source = "engine_snapshot"
output_items: list[dict[str, Any]] = []
generated_image_count = 0
generated_video_count = 0
if gen_type == "image":
output_items = extract_image_output_items(response)
if width <= 0 or height <= 0:
first_sized = next(
(item for item in output_items if safe_int(item.get("width")) > 0 and safe_int(item.get("height")) > 0),
None,
)
if first_sized:
width = safe_int(first_sized.get("width"))
height = safe_int(first_sized.get("height"))
dimension_source = "provider_response"
if width > 0 and height > 0:
for item in output_items:
if not item.get("width") or not item.get("height"):
item.update(
{
"width": width,
"height": height,
"pixels": width * height,
"size_source": dimension_source,
}
)
output_items = sanitize_output_items(output_items)
generated_image_count = len(output_items)
if generated_image_count == 0 and stage == "resource_download_completed" and getattr(owner, "image_url", None):
generated_image_count = 1
output_items = sanitize_output_items(
[{"index": 0, "url": getattr(owner, "image_url"), "size_source": "resource_snapshot"}]
)
elif gen_type == "video":
video_url = response.get("video_url") or response.get("url")
if stage == "resource_download_completed":
video_url = getattr(owner, "video_url", None) or video_url
generated_video_count = 1 if video_url else 0
if video_url:
output_items = sanitize_output_items([{"index": 0, "url": video_url, "type": "video"}])
pricing_meta = response.get("pricing_meta") if isinstance(response.get("pricing_meta"), Mapping) else {}
requested_output_count = max(
1,
safe_int(
pricing_meta.get("requested_output_count"),
safe_int(getattr(owner, "output_count", None), safe_int(getattr(owner, "count", None), 1)),
),
)
output_duration = max(0.0, safe_float(getattr(owner, "duration", None)))
fps = max(0.0, safe_float(getattr(owner, "fps", None), safe_float(getattr(owner, "frame_rate", None))))
generate_audio = safe_bool(getattr(owner, "generate_audio", None), safe_bool(response.get("generate_audio")))
inference_mode = str(
getattr(owner, "inference_mode", None)
or getattr(owner, "service_tier", None)
or response.get("service_tier")
or "online"
).lower()
counts = {
"requested_output_count": requested_output_count,
"generated_image_count": generated_image_count,
"generated_video_count": generated_video_count,
"generated_total_count": generated_image_count + generated_video_count,
}
snapshot = {
"schema_version": 1,
"stage": stage or "unknown",
"gen_type": gen_type,
"requested_output_count": requested_output_count,
"generated_image_count": generated_image_count,
"generated_video_count": generated_video_count,
"generated_total_count": generated_image_count + generated_video_count,
"output_items": output_items,
"duration_seconds": output_duration or None,
"resolution": resolution,
"aspect_ratio": aspect_ratio,
"width": width or None,
"height": height or None,
"dimension_source": dimension_source,
"fps": fps or None,
"generate_audio": generate_audio,
"inference_mode": inference_mode,
}
usage = {
"requested_output_count": requested_output_count,
"generated_image_count": generated_image_count,
"generated_video_count": generated_video_count,
"successful_output_count": generated_image_count if gen_type == "image" else generated_video_count,
"output_items": output_items,
"output_width": width,
"output_height": height,
"dimension_source": dimension_source,
"output_video_duration_seconds": output_duration,
"resolution": str(resolution or "").lower(),
"aspect_ratio": str(aspect_ratio or ""),
"fps": fps,
"generate_audio": generate_audio,
"inference_mode": inference_mode,
"usage_stage": stage or "unknown",
}
return snapshot, counts, usage
@@ -0,0 +1,558 @@
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}")
@@ -0,0 +1,517 @@
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any, Mapping
from sqlalchemy import func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.model_pricing import ModelPricingRuleStatus
from app.models.credit_record import CreditRecord
from app.models.model_pricing_rule import ModelPricingRule
from app.services.model_pricing.calculator import PricingCalculationError, validate_pricing_rule
from app.utils.id_gen import generate_id
PROVIDER_ALIASES = {
"ark": "volcengine",
"volc": "volcengine",
"volc_engine": "volcengine",
"volcano": "volcengine",
"volcengine": "volcengine",
}
class PricingRuleError(ValueError):
pass
def _json_default(value: Any) -> str:
if isinstance(value, datetime):
return value.isoformat()
return str(value)
def canonical_json_hash(value: Mapping[str, Any]) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_default)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def build_rule_content_hash(
*,
model_category: str,
billing_mode: str,
calculator_version: str,
currency: str,
rule_schema_version: int,
rule_json: Mapping[str, Any],
) -> str:
"""规则正文哈希包含所有会改变计算结果的字段,不只哈希 rule_json。"""
return canonical_json_hash(
{
"model_category": model_category,
"billing_mode": billing_mode,
"calculator_version": calculator_version,
"currency": str(currency or "CNY").upper(),
"rule_schema_version": int(rule_schema_version or 1),
"rule_json": normalize_rule_json(rule_json),
}
)
def normalize_rule_json(value: Mapping[str, Any] | None) -> dict[str, Any]:
"""返回全新的普通 dict,所有 ORM 更新必须整体赋值,禁止嵌套原地修改。"""
return deepcopy(dict(value or {}))
def normalize_provider(provider: str | None, model_name: str | None = None) -> str:
value = str(provider or "").strip().lower()
normalized = PROVIDER_ALIASES.get(value, value)
if normalized in {"sdk", "openai_compatible"} and str(model_name or "").strip().lower().startswith("doubao-"):
return "volcengine"
return normalized
def ensure_aware(value: datetime | None) -> datetime:
value = value or datetime.now(timezone.utc)
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def _validate_category_mode(model_category: str, billing_mode: str) -> None:
expected = {
"text_token_tiered": "text",
"image_per_output": "image",
"image_input_output_tiered": "image",
"video_token_rate": "video",
}.get(billing_mode)
if expected is None or model_category != expected:
raise PricingRuleError("模型类型与计价模式不匹配")
def _validate_rule_payload(
*,
model_category: str,
billing_mode: str,
calculator_version: str,
rule_json: Mapping[str, Any],
) -> None:
_validate_category_mode(model_category, billing_mode)
try:
validate_pricing_rule(
billing_mode=billing_mode,
calculator_version=calculator_version,
rule_json=rule_json,
)
except PricingCalculationError as exc:
raise PricingRuleError(str(exc)) from exc
def rule_to_dict(rule: ModelPricingRule) -> dict[str, Any]:
return {
"id": rule.id,
"provider": rule.provider,
"model_name": rule.model_name,
"model_category": rule.model_category,
"billing_mode": rule.billing_mode,
"calculator_version": rule.calculator_version,
"version_code": rule.version_code,
"effective_from": rule.effective_from,
"effective_to": rule.effective_to,
"publish_status": rule.publish_status,
"currency": rule.currency,
"rule_schema_version": rule.rule_schema_version,
"rule_json": deepcopy(rule.rule_json or {}),
"rule_content_hash": rule.rule_content_hash,
"source_url": rule.source_url,
"source_updated_at": rule.source_updated_at,
"remark": rule.remark,
"created_by": rule.created_by,
"updated_by": rule.updated_by,
"created_at": rule.created_at,
"updated_at": rule.updated_at,
}
def _rule_snapshot_query(rule_id: str):
return (
select(
ModelPricingRule.id,
ModelPricingRule.provider,
ModelPricingRule.model_name,
ModelPricingRule.model_category,
ModelPricingRule.billing_mode,
ModelPricingRule.calculator_version,
ModelPricingRule.version_code,
ModelPricingRule.effective_from,
ModelPricingRule.effective_to,
ModelPricingRule.publish_status,
ModelPricingRule.currency,
ModelPricingRule.rule_schema_version,
ModelPricingRule.rule_json,
ModelPricingRule.rule_content_hash,
ModelPricingRule.source_url,
ModelPricingRule.source_updated_at,
ModelPricingRule.remark,
ModelPricingRule.created_by,
ModelPricingRule.updated_by,
ModelPricingRule.created_at,
ModelPricingRule.updated_at,
)
.where(ModelPricingRule.id == rule_id)
.limit(1)
)
def _snapshot_from_mapping(row: Mapping[str, Any]) -> dict[str, Any]:
snapshot = dict(row)
snapshot["rule_json"] = deepcopy(snapshot.get("rule_json") or {})
return snapshot
async def get_rule_snapshot(db: AsyncSession, rule_id: str) -> dict[str, Any]:
"""显式查询并返回普通字典,避免写入后访问过期 ORM 字段触发隐式 IO。"""
row = (await db.execute(_rule_snapshot_query(rule_id))).mappings().one_or_none()
if row is None:
raise PricingRuleError("模型计价规则不存在")
return _snapshot_from_mapping(row)
async def _lock_model_rule_namespace(db: AsyncSession, provider: str, model_name: str) -> None:
bind = db.get_bind()
if bind is not None and bind.dialect.name == "postgresql":
lock_key = f"model_pricing:{provider}:{model_name}"
await db.execute(text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), {"lock_key": lock_key})
async def _assert_unique_version(
db: AsyncSession,
*,
provider: str,
model_name: str,
version_code: str,
exclude_id: str | None = None,
) -> None:
query = select(ModelPricingRule.id).where(
ModelPricingRule.provider == provider,
ModelPricingRule.model_name == model_name,
ModelPricingRule.version_code == version_code,
)
if exclude_id:
query = query.where(ModelPricingRule.id != exclude_id)
if (await db.execute(query.limit(1))).scalar_one_or_none():
raise PricingRuleError("该供应商、模型和价格版本号已存在")
async def _assert_no_overlap(
db: AsyncSession,
*,
provider: str,
model_name: str,
effective_from: datetime,
effective_to: datetime | None,
exclude_id: str | None = None,
) -> None:
query = (
select(ModelPricingRule.id)
.where(ModelPricingRule.provider == provider)
.where(ModelPricingRule.model_name == model_name)
.where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value)
.where(or_(ModelPricingRule.effective_to.is_(None), ModelPricingRule.effective_to > effective_from))
)
if effective_to is not None:
query = query.where(ModelPricingRule.effective_from < effective_to)
if exclude_id:
query = query.where(ModelPricingRule.id != exclude_id)
if (await db.execute(query.limit(1))).scalar_one_or_none():
raise PricingRuleError("该模型已存在生效时间重叠的已发布价格版本")
async def resolve_published_rule(
db: AsyncSession,
*,
provider: str | None,
model_name: str | None,
reference_at: datetime | None = None,
) -> ModelPricingRule | None:
model_name = str(model_name or "").strip()
provider = normalize_provider(provider, model_name)
if not provider or not model_name:
return None
at = ensure_aware(reference_at)
result = await db.execute(
select(ModelPricingRule)
.where(ModelPricingRule.provider == provider)
.where(ModelPricingRule.model_name == model_name)
.where(ModelPricingRule.publish_status.in_([ModelPricingRuleStatus.PUBLISHED.value, ModelPricingRuleStatus.DISABLED.value]))
.where(ModelPricingRule.effective_from <= at)
.where(or_(ModelPricingRule.effective_to.is_(None), ModelPricingRule.effective_to > at))
.order_by(ModelPricingRule.effective_from.desc(), ModelPricingRule.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def list_rules(
db: AsyncSession,
*,
page: int = 1,
page_size: int = 50,
provider: str | None = None,
model_name: str | None = None,
model_category: str | None = None,
publish_status: str | None = None,
) -> dict[str, Any]:
filters = []
if provider:
filters.append(ModelPricingRule.provider == normalize_provider(provider))
if model_name:
filters.append(ModelPricingRule.model_name.ilike(f"%{model_name.strip()}%"))
if model_category:
filters.append(ModelPricingRule.model_category == model_category)
if publish_status:
filters.append(ModelPricingRule.publish_status == publish_status)
total = (await db.execute(select(func.count(ModelPricingRule.id)).where(*filters))).scalar_one()
rows = (
await db.execute(
select(ModelPricingRule)
.where(*filters)
.order_by(ModelPricingRule.model_name, ModelPricingRule.effective_from.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
).scalars().all()
rule_ids = [row.id for row in rows]
referenced: dict[str, int] = {}
if rule_ids:
ref_rows = (
await db.execute(
select(CreditRecord.pricing_rule_id, func.count(CreditRecord.id))
.where(CreditRecord.pricing_rule_id.in_(rule_ids))
.group_by(CreditRecord.pricing_rule_id)
)
).all()
referenced = {str(rule_id): int(count) for rule_id, count in ref_rows if rule_id}
items = []
for row in rows:
item = rule_to_dict(row)
item["referenced_count"] = referenced.get(row.id, 0)
items.append(item)
return {"items": items, "total": int(total or 0)}
async def get_rule(db: AsyncSession, rule_id: str, *, for_update: bool = False) -> ModelPricingRule:
query = select(ModelPricingRule).where(ModelPricingRule.id == rule_id).limit(1)
if for_update:
query = query.with_for_update()
rule = (await db.execute(query)).scalar_one_or_none()
if not rule:
raise PricingRuleError("模型计价规则不存在")
return rule
async def create_rule(db: AsyncSession, *, payload: dict[str, Any], operator_id: str | None) -> dict[str, Any]:
effective_from = ensure_aware(payload["effective_from"])
effective_to = ensure_aware(payload["effective_to"]) if payload.get("effective_to") else None
if effective_to and effective_to <= effective_from:
raise PricingRuleError("失效时间必须晚于生效时间")
model_name = str(payload.get("model_name") or "").strip()
provider = normalize_provider(payload.get("provider"), model_name)
version_code = str(payload.get("version_code") or "").strip()
calculator_version = str(payload.get("calculator_version") or "").strip()
if not provider or not model_name or not version_code or not calculator_version:
raise PricingRuleError("供应商、模型名称、版本号和计算器版本不能为空")
rule_json = normalize_rule_json(payload.get("rule_json"))
_validate_rule_payload(
model_category=payload["model_category"],
billing_mode=payload["billing_mode"],
calculator_version=calculator_version,
rule_json=rule_json,
)
await _lock_model_rule_namespace(db, provider, model_name)
await _assert_unique_version(db, provider=provider, model_name=model_name, version_code=version_code)
rule_id = generate_id()
rule = ModelPricingRule(
id=rule_id,
provider=provider,
model_name=model_name,
model_category=payload["model_category"],
billing_mode=payload["billing_mode"],
calculator_version=calculator_version,
version_code=version_code,
effective_from=effective_from,
effective_to=effective_to,
publish_status=ModelPricingRuleStatus.DRAFT.value,
currency=str(payload.get("currency") or "CNY").upper(),
rule_schema_version=int(payload.get("rule_schema_version") or 1),
rule_json=rule_json,
rule_content_hash=build_rule_content_hash(
model_category=payload["model_category"],
billing_mode=payload["billing_mode"],
calculator_version=calculator_version,
currency=str(payload.get("currency") or "CNY").upper(),
rule_schema_version=int(payload.get("rule_schema_version") or 1),
rule_json=rule_json,
),
source_url=payload.get("source_url"),
source_updated_at=payload.get("source_updated_at"),
remark=payload.get("remark"),
created_by=operator_id,
updated_by=operator_id,
)
db.add(rule)
await db.flush()
return await get_rule_snapshot(db, rule_id)
async def update_draft_rule(
db: AsyncSession,
*,
rule_id: str,
payload: dict[str, Any],
operator_id: str | None,
) -> dict[str, Any]:
rule = await get_rule(db, rule_id, for_update=True)
if rule.publish_status != ModelPricingRuleStatus.DRAFT.value:
raise PricingRuleError("已发布或已停用的价格版本不可修改,请克隆为新版本")
provider = normalize_provider(payload.get("provider", rule.provider), payload.get("model_name", rule.model_name))
model_name = str(payload.get("model_name", rule.model_name) or "").strip()
version_code = str(payload.get("version_code", rule.version_code) or "").strip()
model_category = str(payload.get("model_category", rule.model_category))
billing_mode = str(payload.get("billing_mode", rule.billing_mode))
calculator_version = str(payload.get("calculator_version", rule.calculator_version))
rule_json = normalize_rule_json(payload["rule_json"] if "rule_json" in payload else rule.rule_json)
effective_from = ensure_aware(payload.get("effective_from", rule.effective_from))
effective_to = ensure_aware(payload["effective_to"]) if payload.get("effective_to") else None if "effective_to" in payload else rule.effective_to
if effective_to and effective_to <= effective_from:
raise PricingRuleError("失效时间必须晚于生效时间")
if not provider or not model_name or not version_code or not calculator_version:
raise PricingRuleError("供应商、模型名称、版本号和计算器版本不能为空")
_validate_rule_payload(
model_category=model_category,
billing_mode=billing_mode,
calculator_version=calculator_version,
rule_json=rule_json,
)
await _lock_model_rule_namespace(db, provider, model_name)
await _assert_unique_version(
db,
provider=provider,
model_name=model_name,
version_code=version_code,
exclude_id=rule.id,
)
rule.provider = provider
rule.model_name = model_name
rule.model_category = model_category
rule.billing_mode = billing_mode
rule.calculator_version = calculator_version
rule.version_code = version_code
rule.effective_from = effective_from
rule.effective_to = effective_to
rule.currency = str(payload.get("currency", rule.currency) or "CNY").upper()
rule.rule_schema_version = int(payload.get("rule_schema_version", rule.rule_schema_version) or 1)
rule.rule_json = rule_json
rule.rule_content_hash = build_rule_content_hash(
model_category=rule.model_category,
billing_mode=rule.billing_mode,
calculator_version=rule.calculator_version,
currency=rule.currency,
rule_schema_version=rule.rule_schema_version,
rule_json=rule.rule_json,
)
for key in ("source_url", "source_updated_at", "remark"):
if key in payload:
setattr(rule, key, payload[key])
rule.updated_by = operator_id
await db.flush()
return await get_rule_snapshot(db, rule_id)
async def publish_rule(db: AsyncSession, *, rule_id: str, operator_id: str | None) -> dict[str, Any]:
rule = await get_rule(db, rule_id, for_update=True)
publish_status = rule.publish_status
if publish_status == ModelPricingRuleStatus.PUBLISHED.value:
return await get_rule_snapshot(db, rule_id)
if publish_status != ModelPricingRuleStatus.DRAFT.value:
raise PricingRuleError("只有草稿价格版本可以发布")
provider = rule.provider
model_name = rule.model_name
model_category = rule.model_category
billing_mode = rule.billing_mode
calculator_version = rule.calculator_version
effective_from = rule.effective_from
effective_to = rule.effective_to
currency = rule.currency
rule_schema_version = rule.rule_schema_version
rule_json = normalize_rule_json(rule.rule_json)
await _lock_model_rule_namespace(db, provider, model_name)
_validate_rule_payload(
model_category=model_category,
billing_mode=billing_mode,
calculator_version=calculator_version,
rule_json=rule_json,
)
previous = (
await db.execute(
select(ModelPricingRule)
.where(ModelPricingRule.provider == provider)
.where(ModelPricingRule.model_name == model_name)
.where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value)
.where(ModelPricingRule.effective_from < effective_from)
.where(ModelPricingRule.effective_to.is_(None))
.order_by(ModelPricingRule.effective_from.desc())
.limit(1)
.with_for_update()
)
).scalar_one_or_none()
if previous:
previous.effective_to = effective_from
previous.updated_by = operator_id
await db.flush()
await _assert_no_overlap(
db,
provider=provider,
model_name=model_name,
effective_from=effective_from,
effective_to=effective_to,
exclude_id=rule_id,
)
rule.rule_json = rule_json
rule.rule_content_hash = build_rule_content_hash(
model_category=model_category,
billing_mode=billing_mode,
calculator_version=calculator_version,
currency=currency,
rule_schema_version=rule_schema_version,
rule_json=rule_json,
)
rule.publish_status = ModelPricingRuleStatus.PUBLISHED.value
rule.updated_by = operator_id
await db.flush()
return await get_rule_snapshot(db, rule_id)
async def disable_rule(db: AsyncSession, *, rule_id: str, operator_id: str | None) -> dict[str, Any]:
rule = await get_rule(db, rule_id, for_update=True)
if rule.publish_status == ModelPricingRuleStatus.DISABLED.value:
return await get_rule_snapshot(db, rule_id)
await _lock_model_rule_namespace(db, rule.provider, rule.model_name)
if rule.publish_status == ModelPricingRuleStatus.PUBLISHED.value:
now = datetime.now(timezone.utc)
close_at = now if rule.effective_from < now else rule.effective_from
if rule.effective_to is None or rule.effective_to > close_at:
rule.effective_to = close_at
rule.publish_status = ModelPricingRuleStatus.DISABLED.value
rule.updated_by = operator_id
await db.flush()
return await get_rule_snapshot(db, rule_id)
@@ -0,0 +1,175 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from app.enums.model_pricing import (
ModelPricingBillingMode,
ModelPricingCalculatorVersion,
ModelPricingCategory,
ModelPricingProvider,
PricingBillBy,
)
CST = timezone(timedelta(hours=8))
SOURCE_URL = "https://www.volcengine.com/docs/82379/1544106"
SOURCE_UPDATED_AT = datetime(2026, 7, 9, 12, 2, 6, tzinfo=CST)
# 文档更新时间不等于价格真实生效时间。初始化仅创建草稿;提交前必须逐模型核实并修改。
PROPOSED_EFFECTIVE_FROM = SOURCE_UPDATED_AT
def volcengine_pricing_seed_rules() -> list[dict[str, Any]]:
common = {
"provider": ModelPricingProvider.VOLCENGINE.value,
"publish_status": "draft",
"currency": "CNY",
"rule_schema_version": 1,
"source_url": SOURCE_URL,
"source_updated_at": SOURCE_UPDATED_AT,
"effective_from": PROPOSED_EFFECTIVE_FROM,
"remark_prefix": "初始化草稿:effective_from 仅为建议值,发布前必须核对火山真实生效时间。",
}
rules = [
{
**common,
"model_name": "doubao-seed-2-0-lite-260215",
"model_category": ModelPricingCategory.TEXT.value,
"billing_mode": ModelPricingBillingMode.TEXT_TOKEN_TIERED.value,
"calculator_version": ModelPricingCalculatorVersion.TEXT_TOKEN_TIERED_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"cache_storage_rate_per_million_token_hour": "0.017",
"tiers": [
{"max_context_tokens": 32000, "input_rate": "0.6", "audio_input_rate": "9", "output_rate": "3.6", "cached_input_rate": "0.12", "cached_audio_input_rate": "1.8"},
{"max_context_tokens": 128000, "input_rate": "0.9", "audio_input_rate": "13.5", "output_rate": "5.4", "cached_input_rate": "0.18", "cached_audio_input_rate": "2.7"},
{"max_context_tokens": 256000, "input_rate": "1.8", "audio_input_rate": "27", "output_rate": "10.8", "cached_input_rate": "0.36", "cached_audio_input_rate": "5.4"},
],
},
"remark": "豆包 Seed 2.0 Lite,按上下文长度分档。",
},
{
**common,
"model_name": "doubao-seedream-5-0-pro-260628",
"model_category": ModelPricingCategory.IMAGE.value,
"billing_mode": ModelPricingBillingMode.IMAGE_INPUT_OUTPUT_TIERED.value,
"calculator_version": ModelPricingCalculatorVersion.IMAGE_INPUT_OUTPUT_TIERED_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_image",
"free_input_images": 1,
"input_image_rate": "0.02",
"output_tiers": [
{"max_pixels": 2360000, "rate": "0.30"},
{"max_pixels": None, "rate": "0.60"},
],
"bill_by": PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value,
},
"remark": "Seedream 5.0 Pro:输入图和逐张输出像素分档计价。",
},
{
**common,
"model_name": "doubao-seedream-5-0-260128",
"model_category": ModelPricingCategory.IMAGE.value,
"billing_mode": ModelPricingBillingMode.IMAGE_PER_OUTPUT.value,
"calculator_version": ModelPricingCalculatorVersion.IMAGE_PER_OUTPUT_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {"unit": "CNY_per_image", "output_rate": "0.22", "bill_by": PricingBillBy.SUCCESSFUL_OUTPUT_COUNT.value},
"remark": "Seedream 5.0:按同步接口实际成功输出图片数量计价。",
},
{
**common,
"model_name": "doubao-seedance-2-0-260128",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"token_formula_description": "(input_video_seconds + output_video_seconds) * width * height * fps / 1024",
"default_fps": 30,
"dimension_map": {},
"rates": [
{"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "46"},
{"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "28"},
{"resolutions": ["1080p"], "has_input_video": False, "rate": "51"},
{"resolutions": ["1080p"], "has_input_video": True, "rate": "31"},
{"resolutions": ["4k"], "has_input_video": False, "rate": "26"},
{"resolutions": ["4k"], "has_input_video": True, "rate": "16"},
],
},
"remark": "Seedance 2.0 标准版;dimension_map 空时只接受 Provider 实际 Token,不猜比例像素。",
},
{
**common,
"model_name": "doubao-seedance-2-0-fast-260128",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"default_fps": 30,
"dimension_map": {},
"rates": [
{"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "37"},
{"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "22"},
],
},
"remark": "Seedance 2.0 Fast;仅配置支持的价格档位。",
},
{
**common,
"model_name": "doubao-seedance-2-0-mini-260615",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"default_fps": 30,
"dimension_map": {},
"rates": [
{"resolutions": ["480p", "720p"], "has_input_video": False, "rate": "23"},
{"resolutions": ["480p", "720p"], "has_input_video": True, "rate": "14"},
],
},
"remark": "Seedance 2.0 Mini;仅配置支持的价格档位。",
},
{
**common,
"model_name": "doubao-seedance-1-5-pro-251215",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {
"unit": "CNY_per_million_tokens",
"default_fps": 30,
"dimension_map": {},
"rates": [
{"inference_modes": ["online"], "generate_audio": False, "rate": "8"},
{"inference_modes": ["online"], "generate_audio": True, "rate": "16"},
{"inference_modes": ["flex", "batch"], "generate_audio": False, "rate": "4"},
{"inference_modes": ["flex", "batch"], "generate_audio": True, "rate": "8"},
],
},
"remark": "Seedance 1.5 Pro,按推理模式与有声/无声选择 Token 单价。",
},
{
**common,
"model_name": "doubao-seedance-1-0-pro-250528",
"model_category": ModelPricingCategory.VIDEO.value,
"billing_mode": ModelPricingBillingMode.VIDEO_TOKEN_RATE.value,
"calculator_version": ModelPricingCalculatorVersion.VIDEO_PIXEL_TOKEN_V1.value,
"version_code": "volc_20260709_v1",
"rule_json": {"unit": "CNY_per_million_tokens", "default_fps": 30, "dimension_map": {}, "rates": [{"rate": "15"}]},
"remark": "Seedance 1.0 Pro 固定 Token 单价。",
},
]
for item in rules:
prefix = item.pop("remark_prefix")
item["remark"] = f"{prefix} {item.get('remark') or ''}".strip()
return rules
@@ -0,0 +1,585 @@
from __future__ import annotations
import hashlib
import json
from copy import deepcopy
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Mapping
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.model_pricing import ModelPricingRuleStatus, ProviderCostStatus, PricingSnapshotStage
from app.models.credit_record import CreditRecord
from app.models.model_pricing_rule import ModelPricingRule
from app.services.model_pricing.calculator import PricingCalculationError, calculate_pricing
from app.services.model_pricing.rule_service import normalize_provider, resolve_published_rule
from app.services.operation_log_service import log_model_pricing_event
SNAPSHOT_SCHEMA_VERSION = 1
FINAL_STAGES = {
PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value,
PricingSnapshotStage.PROVIDER_ASYNC_COMPLETED.value,
PricingSnapshotStage.BACKFILL.value,
}
def _json_default(value: Any) -> Any:
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
return str(value)
def _canonical_hash(value: Mapping[str, Any]) -> str:
raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_default)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _reference_at(value: datetime | None) -> datetime:
value = value or _utcnow()
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
def build_refund_pricing_snapshot(charge: Any) -> tuple[dict[str, Any], str]:
snapshot = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"refund": {
"provider_cost": {
"currency": getattr(charge, "provider_cost_currency", None) or "CNY",
"amount": "0",
"status": ProviderCostStatus.NOT_APPLICABLE.value,
"reason": "user_credit_refund_does_not_reverse_provider_cost",
},
"original_charge": {
"credit_record_id": getattr(charge, "id", None),
"pricing_rule_id": getattr(charge, "pricing_rule_id", None),
"pricing_version_code": getattr(charge, "pricing_version_code", None),
"pricing_snapshot_hash": getattr(charge, "pricing_snapshot_hash", None),
"provider_cost_amount": str(getattr(charge, "provider_cost_amount", None) or 0),
"provider_cost_status": getattr(charge, "provider_cost_status", None),
},
},
}
return snapshot, _canonical_hash(snapshot)
def _rule_snapshot(rule: ModelPricingRule) -> dict[str, Any]:
return {
"id": rule.id,
"provider": rule.provider,
"model_name": rule.model_name,
"model_category": rule.model_category,
"billing_mode": rule.billing_mode,
"calculator_version": rule.calculator_version,
"version_code": rule.version_code,
"effective_from": rule.effective_from.isoformat() if rule.effective_from else None,
"effective_to": rule.effective_to.isoformat() if rule.effective_to else None,
"currency": rule.currency,
"rule_schema_version": rule.rule_schema_version,
"rule_content_hash": rule.rule_content_hash,
"rule_json": deepcopy(rule.rule_json or {}),
"source_url": rule.source_url,
"source_updated_at": rule.source_updated_at.isoformat() if rule.source_updated_at else None,
}
def _apply_rule_fields(target: Any, rule: ModelPricingRule, reference_at: datetime) -> None:
target.pricing_rule_id = rule.id
target.pricing_version_code = rule.version_code
target.pricing_billing_mode = rule.billing_mode
target.pricing_calculator_version = rule.calculator_version
target.pricing_reference_at = reference_at
target.pricing_effective_from = rule.effective_from
target.pricing_effective_to = rule.effective_to
target.pricing_snapshot_schema_version = SNAPSHOT_SCHEMA_VERSION
target.provider_cost_currency = rule.currency
def _build_pricing_snapshot(
*,
rule: ModelPricingRule,
result: Any,
stage: str,
audit_metadata: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
# calculated_at 不参与 hash;运行时间单独保存在平铺字段中,保证相同规则/用量快照 hash 稳定。
snapshot = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"stage": stage,
"rule": _rule_snapshot(rule),
"calculation": deepcopy(result.breakdown),
"provider_cost": {
"currency": result.currency,
"amount": str(result.amount),
"status": ProviderCostStatus.ESTIMATED.value if result.is_estimated else ProviderCostStatus.CALCULATED.value,
"is_estimated": bool(result.is_estimated),
"usage_source": result.usage_source,
},
}
if audit_metadata:
snapshot["backfill"] = deepcopy(dict(audit_metadata))
return snapshot
def _build_status_snapshot(
*,
status: str,
stage: str,
rule: ModelPricingRule | None = None,
reason: str | None = None,
audit_metadata: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
snapshot: dict[str, Any] = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"stage": stage,
"provider_cost": {"status": status},
}
if rule is not None:
snapshot["rule"] = _rule_snapshot(rule)
snapshot["provider_cost"]["currency"] = rule.currency
if reason:
snapshot["provider_cost"]["reason"] = reason
if audit_metadata:
snapshot["backfill"] = deepcopy(dict(audit_metadata))
return snapshot
def _apply_result(
target: Any,
*,
rule: ModelPricingRule,
usage: Mapping[str, Any],
result: Any,
stage: str,
audit_metadata: Mapping[str, Any] | None = None,
) -> None:
now = _utcnow()
status = ProviderCostStatus.ESTIMATED.value if result.is_estimated else ProviderCostStatus.CALCULATED.value
pricing_snapshot = _build_pricing_snapshot(
rule=rule,
result=result,
stage=stage,
audit_metadata=audit_metadata,
)
target.provider_cost_amount = result.amount
target.provider_cost_status = status
target.provider_cost_is_estimated = bool(result.is_estimated)
target.provider_cost_calculated_at = now
target.provider_cost_finalized_at = now if stage in FINAL_STAGES else None
target.pricing_usage_source = result.usage_source
target.pricing_snapshot_json = pricing_snapshot
target.usage_snapshot_json = deepcopy(dict(usage))
target.pricing_snapshot_hash = _canonical_hash(pricing_snapshot)
def _can_calculate(billing_mode: str, usage: Mapping[str, Any]) -> bool:
if billing_mode == "text_token_tiered":
return any(int(usage.get(k) or 0) > 0 for k in ("input_tokens", "output_tokens", "cached_input_tokens", "audio_input_tokens"))
if billing_mode in {"image_per_output", "image_input_output_tiered"}:
return int(usage.get("successful_output_count") or usage.get("provider_billed_count") or 0) > 0
if billing_mode == "video_token_rate":
if int(usage.get("total_tokens") or 0) > 0:
return True
return float(usage.get("output_video_duration_seconds") or 0) > 0
return False
def _apply_not_applicable(target: Any, usage: Mapping[str, Any], reason: str) -> None:
target.provider_cost_status = ProviderCostStatus.NOT_APPLICABLE.value
target.provider_cost_amount = Decimal("0")
target.provider_cost_is_estimated = False
target.provider_usage_primary = False
target.usage_snapshot_json = deepcopy(dict(usage)) or None
target.pricing_snapshot_json = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"provider_cost": {
"status": ProviderCostStatus.NOT_APPLICABLE.value,
"amount": "0",
"reason": reason,
},
}
target.pricing_snapshot_hash = _canonical_hash(target.pricing_snapshot_json)
async def enrich_credit_meta_with_pricing(
db: AsyncSession,
*,
meta: Any,
usage: Mapping[str, Any] | None = None,
reference_at: datetime | None = None,
final: bool = False,
) -> Any:
usage_dict = deepcopy(dict(usage or {}))
reference = _reference_at(reference_at)
if getattr(meta, "charge_kind", None) in {"file_parse", "vision_input"}:
meta.pricing_reference_at = reference
_apply_not_applicable(meta, usage_dict, "cost_included_in_primary_text_prompt_charge")
return meta
provider = getattr(meta, "engine_provider", None)
model_name = getattr(meta, "engine_model_name", None)
if not provider or not model_name:
meta.pricing_reference_at = reference
meta.provider_cost_status = ProviderCostStatus.PENDING.value if not final else ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value
meta.usage_snapshot_json = usage_dict or None
return meta
rule = await resolve_published_rule(db, provider=provider, model_name=model_name, reference_at=reference)
if not rule:
meta.pricing_reference_at = reference
meta.provider_cost_status = ProviderCostStatus.UNMATCHED_RULE.value
meta.usage_snapshot_json = usage_dict or None
return meta
_apply_rule_fields(meta, rule, reference)
meta.provider_usage_primary = bool(usage_dict.get("provider_usage_primary", True))
meta.usage_snapshot_json = usage_dict or None
# 媒体扣费创建阶段只锁定规则与请求快照。图片等待同步生成响应,视频等待异步 Provider 完成;
# 不能在请求时用预设时长/分辨率提前写入估算成本。
if not final:
meta.provider_cost_status = ProviderCostStatus.PENDING.value
meta.provider_cost_amount = None
meta.provider_cost_is_estimated = False
meta.pricing_snapshot_json = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"stage": PricingSnapshotStage.REQUEST_LOCKED.value,
"rule": _rule_snapshot(rule),
"provider_cost": {"currency": rule.currency, "status": meta.provider_cost_status},
}
meta.pricing_snapshot_hash = _canonical_hash(meta.pricing_snapshot_json)
return meta
if not _can_calculate(rule.billing_mode, usage_dict):
meta.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value
meta.pricing_snapshot_json = {
"schema_version": SNAPSHOT_SCHEMA_VERSION,
"stage": PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value,
"rule": _rule_snapshot(rule),
"provider_cost": {"currency": rule.currency, "status": meta.provider_cost_status},
}
meta.pricing_snapshot_hash = _canonical_hash(meta.pricing_snapshot_json)
return meta
try:
result = calculate_pricing(
billing_mode=rule.billing_mode,
calculator_version=rule.calculator_version,
rule_json=rule.rule_json or {},
usage=usage_dict,
currency=rule.currency,
)
_apply_result(
meta,
rule=rule,
usage=usage_dict,
result=result,
stage=PricingSnapshotStage.PROVIDER_SYNC_COMPLETED.value if final else PricingSnapshotStage.REQUEST_LOCKED.value,
)
except PricingCalculationError:
meta.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value if final else ProviderCostStatus.PENDING.value
except Exception as exc:
meta.provider_cost_status = ProviderCostStatus.ERROR.value
log_model_pricing_event(
event_type="pricing_cost_calculate",
event_status="failed",
owner_type=getattr(meta, "owner_type", None),
owner_id=getattr(meta, "owner_id", None),
pricing_rule_id=rule.id,
pricing_version=rule.version_code,
provider=provider,
model_name=model_name,
billing_mode=rule.billing_mode,
cost_status=meta.provider_cost_status,
error=str(exc),
)
return meta
async def _load_locked_rule(
db: AsyncSession,
charge: CreditRecord,
*,
reference_at: datetime,
use_locked_rule: bool,
) -> ModelPricingRule | None:
if use_locked_rule and charge.pricing_rule_id:
return (
await db.execute(select(ModelPricingRule).where(ModelPricingRule.id == charge.pricing_rule_id).limit(1))
).scalar_one_or_none()
if not use_locked_rule:
provider = normalize_provider(charge.engine_provider, charge.engine_model_name)
model_name = str(charge.engine_model_name or "").strip()
if not provider or not model_name:
return None
return (
await db.execute(
select(ModelPricingRule)
.where(ModelPricingRule.provider == provider)
.where(ModelPricingRule.model_name == model_name)
.where(ModelPricingRule.publish_status == ModelPricingRuleStatus.PUBLISHED.value)
.where(ModelPricingRule.effective_from <= reference_at)
.where(
(ModelPricingRule.effective_to.is_(None))
| (ModelPricingRule.effective_to > reference_at)
)
.order_by(ModelPricingRule.effective_from.desc(), ModelPricingRule.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
return await resolve_published_rule(
db,
provider=charge.engine_provider,
model_name=charge.engine_model_name,
reference_at=reference_at,
)
async def _missing_rule_status(db: AsyncSession, *, charge: CreditRecord, reference_at: datetime) -> str:
model_name = str(charge.engine_model_name or "").strip()
provider = normalize_provider(charge.engine_provider, model_name)
if not provider or not model_name:
return ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value
earliest = (
await db.execute(
select(func.min(ModelPricingRule.effective_from)).where(
ModelPricingRule.provider == provider,
ModelPricingRule.model_name == model_name,
)
)
).scalar_one_or_none()
if earliest and _reference_at(reference_at) < _reference_at(earliest):
return ProviderCostStatus.HISTORICAL_PRICE_UNAVAILABLE.value
return ProviderCostStatus.UNMATCHED_RULE.value
def _merge_snapshot(existing: Mapping[str, Any] | None, incoming: Mapping[str, Any] | None) -> dict[str, Any] | None:
if incoming is None:
return deepcopy(dict(existing or {})) or None
merged = deepcopy(dict(existing or {}))
merged.update(deepcopy(dict(incoming)))
return merged
async def finalize_credit_record_pricing(
db: AsyncSession,
*,
charge: CreditRecord,
usage: Mapping[str, Any] | None,
stage: str,
attachment_snapshot: Mapping[str, Any] | None = None,
attachment_counts: Mapping[str, Any] | None = None,
generation_snapshot: Mapping[str, Any] | None = None,
generation_counts: Mapping[str, Any] | None = None,
allow_upgrade_estimated: bool = True,
pricing_reference_at: datetime | None = None,
use_locked_rule: bool = True,
force_reprice: bool = False,
backfill_metadata: Mapping[str, Any] | None = None,
) -> CreditRecord:
"""同一事务内回填,不 commit;JSON 一律构建新对象后整体赋值。
正常生成链路保持默认行为:使用请求时已锁定的规则,已核算成本不可覆盖。
历史补录可显式传入统一的当前计价时点、忽略旧规则绑定并强制重算。
"""
if attachment_snapshot is not None:
charge.attachment_snapshot_json = deepcopy(dict(attachment_snapshot))
for key, value in (attachment_counts or {}).items():
if hasattr(charge, key):
setattr(charge, key, value)
if generation_snapshot is not None:
charge.generation_snapshot_json = _merge_snapshot(charge.generation_snapshot_json, generation_snapshot)
for key, value in (generation_counts or {}).items():
if hasattr(charge, key):
setattr(charge, key, value)
# 资源下载完成只补资源快照;同步图片/异步视频成本均不得在下载阶段重算。
if stage == PricingSnapshotStage.RESOURCE_DOWNLOAD_COMPLETED.value:
return charge
if charge.type != "consume" or charge.charge_action != "charge":
_apply_not_applicable(charge, dict(usage or {}), "only_consume_charge_can_be_priced")
return charge
current_status = charge.provider_cost_status
if not force_reprice:
if current_status == ProviderCostStatus.CALCULATED.value:
return charge
if current_status == ProviderCostStatus.ESTIMATED.value and not allow_upgrade_estimated:
return charge
usage_dict = deepcopy(dict(usage or {}))
reference = _reference_at(
pricing_reference_at
if pricing_reference_at is not None
else (charge.pricing_reference_at or charge.created_at)
)
rule = await _load_locked_rule(
db,
charge,
reference_at=reference,
use_locked_rule=use_locked_rule,
)
if not rule:
charge.pricing_reference_at = reference
if not charge.engine_provider or not charge.engine_model_name:
status = ProviderCostStatus.HISTORICAL_ENGINE_UNAVAILABLE.value
elif use_locked_rule:
status = await _missing_rule_status(db, charge=charge, reference_at=reference)
else:
status = ProviderCostStatus.UNMATCHED_RULE.value
charge.provider_cost_status = status
charge.provider_cost_amount = None
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = None
charge.provider_cost_finalized_at = None
charge.usage_snapshot_json = usage_dict or None
charge.pricing_snapshot_json = _build_status_snapshot(
status=status,
stage=stage,
reason="current_published_rule_not_found" if not use_locked_rule else "pricing_rule_not_found",
audit_metadata=backfill_metadata,
)
charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json)
return charge
_apply_rule_fields(charge, rule, reference)
charge.provider_usage_primary = bool(usage_dict.get("provider_usage_primary", True))
if not _can_calculate(rule.billing_mode, usage_dict):
charge.provider_cost_status = (
ProviderCostStatus.USAGE_MISSING.value
if stage in FINAL_STAGES
else ProviderCostStatus.PENDING.value
)
charge.provider_cost_amount = None
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = None
charge.provider_cost_finalized_at = None
charge.usage_snapshot_json = usage_dict or None
charge.pricing_snapshot_json = _build_status_snapshot(
status=charge.provider_cost_status,
stage=stage,
rule=rule,
reason="pricing_usage_missing",
audit_metadata=backfill_metadata,
)
charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json)
return charge
try:
result = calculate_pricing(
billing_mode=rule.billing_mode,
calculator_version=rule.calculator_version,
rule_json=rule.rule_json or {},
usage=usage_dict,
currency=rule.currency,
)
if (
not force_reprice
and current_status == ProviderCostStatus.ESTIMATED.value
and result.is_estimated
):
return charge
_apply_result(
charge,
rule=rule,
usage=usage_dict,
result=result,
stage=stage,
audit_metadata=backfill_metadata,
)
log_model_pricing_event(
event_type="pricing_snapshot_persist",
user_id=charge.user_id,
credit_record_id=charge.id,
owner_type=charge.owner_type,
owner_id=charge.owner_id,
pricing_rule_id=rule.id,
pricing_version=rule.version_code,
provider=charge.engine_provider,
model_name=charge.engine_model_name,
billing_mode=rule.billing_mode,
cost_status=charge.provider_cost_status,
provider_cost=charge.provider_cost_amount,
is_estimated=charge.provider_cost_is_estimated,
detail={
"stage": stage,
"usage_source": charge.pricing_usage_source,
"backfill": deepcopy(dict(backfill_metadata or {})) or None,
},
)
except PricingCalculationError as exc:
charge.provider_cost_status = ProviderCostStatus.USAGE_MISSING.value
charge.provider_cost_amount = None
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = None
charge.provider_cost_finalized_at = None
charge.usage_snapshot_json = usage_dict or None
charge.pricing_snapshot_json = _build_status_snapshot(
status=charge.provider_cost_status,
stage=stage,
rule=rule,
reason=str(exc),
audit_metadata=backfill_metadata,
)
charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json)
log_model_pricing_event(
event_type="pricing_snapshot_failed",
event_status="warning",
user_id=charge.user_id,
credit_record_id=charge.id,
owner_type=charge.owner_type,
owner_id=charge.owner_id,
pricing_rule_id=rule.id,
pricing_version=rule.version_code,
provider=charge.engine_provider,
model_name=charge.engine_model_name,
billing_mode=rule.billing_mode,
cost_status=charge.provider_cost_status,
error=str(exc),
detail={"stage": stage, "backfill": deepcopy(dict(backfill_metadata or {})) or None},
)
except Exception as exc:
charge.provider_cost_status = ProviderCostStatus.ERROR.value
charge.provider_cost_amount = None
charge.provider_cost_is_estimated = False
charge.provider_cost_calculated_at = None
charge.provider_cost_finalized_at = None
charge.usage_snapshot_json = usage_dict or None
charge.pricing_snapshot_json = _build_status_snapshot(
status=charge.provider_cost_status,
stage=stage,
rule=rule,
reason=str(exc),
audit_metadata=backfill_metadata,
)
charge.pricing_snapshot_hash = _canonical_hash(charge.pricing_snapshot_json)
log_model_pricing_event(
event_type="pricing_snapshot_failed",
event_status="failed",
user_id=charge.user_id,
credit_record_id=charge.id,
owner_type=charge.owner_type,
owner_id=charge.owner_id,
pricing_rule_id=rule.id,
pricing_version=rule.version_code,
provider=charge.engine_provider,
model_name=charge.engine_model_name,
billing_mode=rule.billing_mode,
cost_status=charge.provider_cost_status,
error=str(exc),
detail={"stage": stage, "backfill": deepcopy(dict(backfill_metadata or {})) or None},
)
return charge
@@ -0,0 +1,275 @@
from __future__ import annotations
import hashlib
import json
import re
from copy import deepcopy
from typing import Any, Mapping
from urllib.parse import urlsplit, urlunsplit
def safe_json_dict(value: Any) -> dict[str, Any]:
"""Best-effort JSON object conversion without leaking parse failures into billing."""
if isinstance(value, Mapping):
return deepcopy(dict(value))
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return deepcopy(dict(parsed)) if isinstance(parsed, Mapping) else {}
except Exception:
return {}
return {}
def safe_int(value: Any, default: int = 0) -> int:
try:
if value in (None, ""):
return default
return int(float(value))
except Exception:
return default
def safe_float(value: Any, default: float = 0.0) -> float:
try:
if value in (None, ""):
return default
return float(value)
except Exception:
return default
def safe_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value in (None, ""):
return default
if isinstance(value, (int, float)):
return value != 0
text = str(value).strip().lower()
if text in {"1", "true", "yes", "on", "enabled"}:
return True
if text in {"0", "false", "no", "off", "disabled"}:
return False
return default
def parse_size(value: Any) -> tuple[int, int]:
text = str(value or "").lower().replace("×", "x")
match = re.search(r"(\d{2,5})\s*x\s*(\d{2,5})", text)
if not match:
return 0, 0
return int(match.group(1)), int(match.group(2))
def _extract_usage(data: Mapping[str, Any]) -> dict[str, Any]:
candidates = [
data.get("usage"),
(data.get("data") or {}).get("usage") if isinstance(data.get("data"), Mapping) else None,
(data.get("result") or {}).get("usage") if isinstance(data.get("result"), Mapping) else None,
]
for value in candidates:
if isinstance(value, Mapping):
return deepcopy(dict(value))
return {}
def normalize_text_pricing_usage(
raw_usage: Mapping[str, Any] | None,
*,
base: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Normalize Ark/OpenAI text usage and retain cache/audio dimensions."""
raw = deepcopy(dict(raw_usage or {}))
result = deepcopy(dict(base or {}))
input_tokens = safe_int(result.get("input_tokens"), safe_int(raw.get("input_tokens"), safe_int(raw.get("prompt_tokens"))))
output_tokens = safe_int(result.get("output_tokens"), safe_int(raw.get("output_tokens"), safe_int(raw.get("completion_tokens"))))
total_tokens = safe_int(result.get("total_tokens"), safe_int(raw.get("total_tokens"), input_tokens + output_tokens))
details: dict[str, Any] = {}
for key in ("prompt_tokens_details", "input_tokens_details"):
value = raw.get(key)
if isinstance(value, Mapping):
details.update(deepcopy(dict(value)))
cached_input_tokens = safe_int(
result.get("cached_input_tokens"),
safe_int(raw.get("cached_input_tokens"), safe_int(details.get("cached_tokens"), safe_int(details.get("cache_read_tokens")))),
)
audio_input_tokens = safe_int(
result.get("audio_input_tokens"),
safe_int(raw.get("audio_input_tokens"), safe_int(details.get("audio_tokens"))),
)
cached_audio_input_tokens = safe_int(
result.get("cached_audio_input_tokens"),
safe_int(raw.get("cached_audio_input_tokens"), safe_int(details.get("cached_audio_tokens"))),
)
result.update(
{
"input_tokens": max(0, input_tokens),
"output_tokens": max(0, output_tokens),
"total_tokens": max(0, total_tokens),
"context_tokens": max(0, safe_int(result.get("context_tokens"), input_tokens)),
"cached_input_tokens": max(0, min(input_tokens, cached_input_tokens)),
"audio_input_tokens": max(0, min(input_tokens, audio_input_tokens)),
"cached_audio_input_tokens": max(0, min(input_tokens, audio_input_tokens, cached_audio_input_tokens)),
"cache_storage_tokens": max(0, safe_int(result.get("cache_storage_tokens"), safe_int(raw.get("cache_storage_tokens")))),
"cache_storage_duration_hours": max(
0.0,
safe_float(result.get("cache_storage_duration_hours"), safe_float(raw.get("cache_storage_duration_hours"))),
),
"provider_usage_primary": safe_bool(result.get("provider_usage_primary"), True),
"usage_source": result.get("usage_source") or "provider",
}
)
if details:
result["provider_input_token_details"] = details
return result
def extract_image_output_items(provider_response: Any) -> list[dict[str, Any]]:
"""Only parse explicit synchronous image output items; never recurse through arbitrary URLs."""
data = safe_json_dict(provider_response)
raw_items = data.get("data")
if isinstance(raw_items, Mapping):
raw_items = raw_items.get("items") or raw_items.get("data")
if not isinstance(raw_items, list):
raw_items = (data.get("result") or {}).get("data") if isinstance(data.get("result"), Mapping) else None
if not isinstance(raw_items, list):
return []
items: list[dict[str, Any]] = []
for index, raw in enumerate(raw_items):
if not isinstance(raw, Mapping):
continue
url = raw.get("url") or raw.get("image_url")
width = safe_int(raw.get("width"))
height = safe_int(raw.get("height"))
if width <= 0 or height <= 0:
width, height = parse_size(raw.get("size"))
item = {
"index": index,
"url": str(url) if url else None,
"width": width or None,
"height": height or None,
"pixels": width * height if width > 0 and height > 0 else None,
"size": raw.get("size"),
"size_source": "provider_response" if width > 0 and height > 0 else "unavailable",
}
# A valid provider output may omit a URL in rare response formats, but it must
# still be represented for generated-count and pixel-tier accounting.
items.append({key: value for key, value in item.items() if value is not None})
return items
def sanitize_output_items(items: list[Mapping[str, Any]]) -> list[dict[str, Any]]:
"""Remove volatile signed URLs before persisting pricing/generation snapshots."""
sanitized: list[dict[str, Any]] = []
for raw in items:
item = deepcopy(dict(raw))
url = str(item.pop("url", "") or "").strip()
if url:
try:
parts = urlsplit(url)
normalized = (
urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, "", ""))
if parts.scheme and parts.netloc
else url.split("?", 1)[0].split("#", 1)[0]
)
except Exception:
normalized = url.split("?", 1)[0].split("#", 1)[0]
item["url_sha256"] = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
sanitized.append(item)
return sanitized
def normalize_provider_media_usage(
provider_response: Any,
*,
gen_type: str,
fallback_total_tokens: int = 0,
request_image_px: str | None = None,
requested_output_count: int = 1,
provider_input_image_count: int = 0,
) -> dict[str, Any]:
"""Normalize Volcengine synchronous-image or asynchronous-video response usage."""
data = safe_json_dict(provider_response)
raw_usage = _extract_usage(data)
input_tokens = safe_int(raw_usage.get("input_tokens"), safe_int(raw_usage.get("prompt_tokens")))
output_tokens = safe_int(
raw_usage.get("output_tokens"),
safe_int(raw_usage.get("completion_tokens"), safe_int(raw_usage.get("generated_tokens"))),
)
total_tokens = safe_int(raw_usage.get("total_tokens"), fallback_total_tokens)
if total_tokens <= 0:
total_tokens = input_tokens + output_tokens
if output_tokens <= 0 and total_tokens > input_tokens:
output_tokens = total_tokens - input_tokens
result: dict[str, Any] = deepcopy(dict(raw_usage))
result.update(
{
"input_tokens": max(0, input_tokens),
"output_tokens": max(0, output_tokens),
"total_tokens": max(0, total_tokens),
"requested_output_count": max(1, requested_output_count),
"provider_usage_primary": True,
}
)
pricing_meta = data.get("pricing_meta") if isinstance(data.get("pricing_meta"), Mapping) else {}
if gen_type == "image":
output_items = extract_image_output_items(data)
fallback_width, fallback_height = parse_size(request_image_px)
for item in output_items:
if not item.get("width") and fallback_width > 0 and fallback_height > 0:
item.update(
{
"width": fallback_width,
"height": fallback_height,
"pixels": fallback_width * fallback_height,
"size_source": "request_explicit",
}
)
output_items = sanitize_output_items(output_items)
provider_count = safe_int(
pricing_meta.get("provider_input_image_count"),
safe_int(data.get("provider_input_image_count"), provider_input_image_count),
)
generated = len(output_items) or safe_int(raw_usage.get("generated_images"))
provider_billed = safe_int(
raw_usage.get("billed_images"),
safe_int(pricing_meta.get("provider_billed_count"), max(0, generated)),
)
result.update(
{
"provider_input_image_count": max(0, provider_count),
"input_image_count": max(0, provider_count),
"output_items": output_items,
"successful_output_count": max(0, generated),
"provider_billed_count": max(0, provider_billed),
"usage_source": "provider_response",
}
)
return result
width = safe_int(raw_usage.get("width"), safe_int(data.get("width")))
height = safe_int(raw_usage.get("height"), safe_int(data.get("height")))
if width <= 0 or height <= 0:
width, height = parse_size(raw_usage.get("size") or data.get("size"))
result.update(
{
"output_width": width,
"output_height": height,
"dimension_source": "provider_response" if width > 0 and height > 0 else "unavailable",
"fps": safe_float(raw_usage.get("fps"), safe_float(data.get("fps"))),
"resolution": str(raw_usage.get("resolution") or data.get("resolution") or "").lower(),
"aspect_ratio": str(raw_usage.get("aspect_ratio") or data.get("aspect_ratio") or data.get("ratio") or ""),
"generate_audio": safe_bool(raw_usage.get("generate_audio"), safe_bool(data.get("generate_audio"))),
"inference_mode": str(raw_usage.get("inference_mode") or data.get("service_tier") or "online").lower(),
"usage_source": "provider" if total_tokens > 0 else "provider_response",
}
)
return result