1
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user