AI计价规则BUG修复

This commit is contained in:
2026-07-10 17:26:11 +08:00
parent f00c36c262
commit 0fd93e53b9
5 changed files with 136 additions and 24 deletions
@@ -116,6 +116,28 @@ def _normalize_frontend_kind(value: str | None) -> str:
return value or FrontendUserKind.EXTERNAL.value
_ATTACHMENT_META_FIELDS = (
"attachment_image_count",
"attachment_video_count",
"attachment_audio_count",
"attachment_total_count",
"attachment_video_duration_seconds",
"attachment_audio_duration_seconds",
)
def _apply_attachment_counts(meta: CreditRecordMeta, counts: Mapping[str, Any]) -> None:
"""只把 CreditRecord 实际存在的附件聚合字段平铺到元数据对象。
provider_input_* 属于供应商 usage 快照,不是 CreditRecordMeta/credit_records 顶层字段。
CreditRecordMeta 使用 slots=True,动态 setattr 会直接抛 AttributeError。
"""
for key in _ATTACHMENT_META_FIELDS:
value = counts.get(key)
if value is not None:
setattr(meta, key, value)
async def with_user_snapshot(db: AsyncSession, meta: CreditRecordMeta, user_id: str) -> CreditRecordMeta:
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
@@ -286,6 +308,7 @@ async def build_generation_media_meta(
inference_mode: str | None = None,
input_video_duration: float | None = None,
requested_output_count: int = 1,
provider_uses_media_references: bool | None = None,
) -> CreditRecordMeta:
media_type = (gen_type or "").lower().strip() or None
if source_module is None:
@@ -309,9 +332,15 @@ async def build_generation_media_meta(
for key, value in (await get_engine_snapshot(db, gen_type=media_type or "", engine_id=engine_id)).items():
setattr(meta, key, value)
requested_count = max(1, _safe_int(requested_output_count, 1))
attachment_snapshot, attachment_counts = build_attachment_snapshot(media_references)
for key, value in attachment_counts.items():
setattr(meta, key, value)
if provider_uses_media_references is None:
# GenerationRecord 的附件只参与前置提示词优化,媒体供应商调用明确不再携带;
# ChatGenerationTask/模块任务则会在创建供应商任务时携带附件。
provider_uses_media_references = owner_type != CreditRecordOwnerType.GENERATION_RECORD.value
attachment_snapshot, attachment_counts = build_attachment_snapshot(
media_references,
allow_provider_input=provider_uses_media_references,
)
_apply_attachment_counts(meta, attachment_counts)
meta.attachment_snapshot_json = attachment_snapshot
width, height = parse_dimensions(image_px, image_size)
@@ -340,6 +369,16 @@ async def build_generation_media_meta(
"inference_mode": inference_mode or "online",
"stage": "request_locked",
}
provider_input_image_count = _safe_int(attachment_counts.get("provider_input_image_count"))
provider_input_video_count = _safe_int(attachment_counts.get("provider_input_video_count"))
provider_input_audio_count = _safe_int(attachment_counts.get("provider_input_audio_count"))
provider_input_video_duration = float(
input_video_duration
if input_video_duration is not None
else attachment_counts["attachment_video_duration_seconds"] or 0
)
provider_input_audio_duration = float(attachment_counts["attachment_audio_duration_seconds"] or 0)
request_usage = {
"resolution": str(resolution or "").lower(),
"aspect_ratio": str(aspect_ratio or ""),
@@ -347,11 +386,13 @@ async def build_generation_media_meta(
"output_height": height,
"dimension_source": dimension_source,
"output_video_duration_seconds": float(duration or 0),
"input_video_duration_seconds": float(input_video_duration or attachment_counts["attachment_video_duration_seconds"] or 0),
"input_audio_duration_seconds": float(attachment_counts["attachment_audio_duration_seconds"] or 0),
"provider_input_image_count": attachment_counts.get("provider_input_image_count", 0),
"input_image_count": attachment_counts.get("provider_input_image_count", 0),
"has_input_video": bool(attachment_counts.get("provider_input_video_count", 0) or input_video_duration),
"input_video_duration_seconds": provider_input_video_duration,
"input_audio_duration_seconds": provider_input_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,
"input_image_count": provider_input_image_count,
"has_input_video": bool(provider_input_video_count or provider_input_video_duration),
"requested_output_count": requested_count,
"successful_output_count": 0,
"fps": float(fps or 0),
@@ -369,6 +410,7 @@ async def build_generation_record_prompt_meta(
attempt_no: int,
charge_kind: str,
usage: Mapping[str, Any],
media_references: Any = None,
) -> CreditRecordMeta:
scene_map = {
CreditRecordChargeKind.TEXT_PROMPT.value: CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value,
@@ -390,6 +432,12 @@ async def build_generation_record_prompt_meta(
total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))),
)
meta = await _apply_model_snapshot(db, meta, usage)
attachment_snapshot, attachment_counts = build_attachment_snapshot(
media_references,
allow_provider_input=True,
)
_apply_attachment_counts(meta, attachment_counts)
meta.attachment_snapshot_json = attachment_snapshot
return await enrich_credit_meta_with_pricing(db, meta=meta, usage=usage, final=True)
@@ -241,6 +241,7 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT,
usage=usage,
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -270,6 +271,7 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_FILE_PARSE,
usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0},
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -299,6 +301,7 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no,
charge_kind=CHARGE_VISION_INPUT,
usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0},
media_references=record.media_references,
)
items.append(
await deduct_credits_locked_once(
@@ -477,6 +480,7 @@ async def charge_generation_media_by_params(
billing_scene: str | None = None,
media_references: Any = None,
requested_output_count: int = 1,
provider_uses_media_references: bool | None = None,
) -> BillingSummary:
"""图片/视频媒体生成扣费。
@@ -522,6 +526,7 @@ async def charge_generation_media_by_params(
inference_mode=inference_mode,
input_video_duration=input_video_duration,
requested_output_count=requested_output_count,
provider_uses_media_references=provider_uses_media_references,
)
if gen_type == "image":
@@ -589,4 +594,5 @@ async def charge_generation_media_for_record(
attempt_no=attempt_no,
source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
media_references=record.media_references,
provider_uses_media_references=False,
)
@@ -17,6 +17,7 @@ from app.models.token_usage import TokenUsage
from app.services.model_pricing.attachment_snapshot_service import build_attachment_snapshot, build_generation_snapshot
from app.services.model_pricing.usage_normalizer import (
normalize_provider_media_usage,
safe_float,
safe_int,
safe_json_dict,
)
@@ -150,35 +151,75 @@ async def _sync_charge_snapshot(
return None
response = provider_response if provider_response is not None else getattr(owner, "provider_response_json", None)
attachment_snapshot, attachment_counts = build_attachment_snapshot(getattr(owner, "media_references", None))
provider_uses_media_references = charge.owner_type != CreditRecordOwnerType.GENERATION_RECORD.value
attachment_snapshot, attachment_counts = build_attachment_snapshot(
getattr(owner, "media_references", None),
allow_provider_input=provider_uses_media_references,
)
generation_snapshot, generation_counts, generation_usage = build_generation_snapshot(
owner,
provider_response=response,
stage=stage,
)
existing_usage = deepcopy(dict(charge.usage_snapshot_json or {}))
locked_input_image_count = safe_int(
existing_usage.get("provider_input_image_count"),
safe_int(attachment_counts.get("provider_input_image_count")),
)
locked_input_video_count = safe_int(
existing_usage.get("provider_input_video_count"),
safe_int(attachment_counts.get("provider_input_video_count")),
)
locked_input_audio_count = safe_int(
existing_usage.get("provider_input_audio_count"),
safe_int(attachment_counts.get("provider_input_audio_count")),
)
locked_input_video_duration = safe_float(
existing_usage.get("input_video_duration_seconds"),
safe_float(attachment_counts.get("attachment_video_duration_seconds")),
)
locked_input_audio_duration = safe_float(
existing_usage.get("input_audio_duration_seconds"),
safe_float(attachment_counts.get("attachment_audio_duration_seconds")),
)
provider_usage = normalize_provider_media_usage(
response,
gen_type=gen_type,
fallback_total_tokens=fallback_total,
request_image_px=getattr(owner, "image_px", None),
requested_output_count=max(1, safe_int(generation_counts.get("requested_output_count"), 1)),
provider_input_image_count=safe_int(attachment_counts.get("provider_input_image_count")),
provider_input_image_count=locked_input_image_count,
)
usage = {**existing_usage, **generation_usage, **provider_usage}
provider_input_image_count = safe_int(
provider_usage.get("provider_input_image_count"),
locked_input_image_count,
)
provider_input_video_count = safe_int(
provider_usage.get("provider_input_video_count"),
locked_input_video_count,
)
provider_input_audio_count = safe_int(
provider_usage.get("provider_input_audio_count"),
locked_input_audio_count,
)
input_video_duration_seconds = safe_float(
provider_usage.get("input_video_duration_seconds"),
locked_input_video_duration,
)
input_audio_duration_seconds = safe_float(
provider_usage.get("input_audio_duration_seconds"),
locked_input_audio_duration,
)
usage.update(
{
"has_input_video": safe_int(attachment_counts.get("provider_input_video_count")) > 0,
"provider_input_image_count": safe_int(
provider_usage.get("provider_input_image_count"),
safe_int(attachment_counts.get("provider_input_image_count")),
),
"input_image_count": safe_int(
provider_usage.get("provider_input_image_count"),
safe_int(attachment_counts.get("provider_input_image_count")),
),
"input_video_duration_seconds": float(attachment_counts.get("attachment_video_duration_seconds") or 0),
"input_audio_duration_seconds": float(attachment_counts.get("attachment_audio_duration_seconds") or 0),
"has_input_video": bool(provider_input_video_count or input_video_duration_seconds),
"provider_input_image_count": provider_input_image_count,
"provider_input_video_count": provider_input_video_count,
"provider_input_audio_count": provider_input_audio_count,
"input_image_count": provider_input_image_count,
"input_video_duration_seconds": input_video_duration_seconds,
"input_audio_duration_seconds": input_audio_duration_seconds,
"usage_stage": stage,
}
)
@@ -80,7 +80,15 @@ def _walk_reference_items(value: Any) -> Iterable[Mapping[str, Any]]:
yield from _walk_reference_items(child)
def _billable_input(raw: Mapping[str, Any], media_type: str) -> bool:
def _billable_input(
raw: Mapping[str, Any],
media_type: str,
*,
allow_provider_input: bool,
) -> bool:
# 是否作为供应商直接输入由服务端调用链决定,不能信任客户端附件字段。
if not allow_provider_input:
return False
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()
@@ -89,7 +97,11 @@ def _billable_input(raw: Mapping[str, Any], media_type: str) -> bool:
return media_type in MEDIA_TYPES
def build_attachment_snapshot(media_references: Any) -> tuple[dict[str, Any], dict[str, Any]]:
def build_attachment_snapshot(
media_references: Any,
*,
allow_provider_input: bool = True,
) -> tuple[dict[str, Any], dict[str, Any]]:
items: list[dict[str, Any]] = []
image_count = video_count = audio_count = 0
provider_input_image_count = 0
@@ -118,7 +130,11 @@ def build_attachment_snapshot(media_references: Any) -> tuple[dict[str, Any], di
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)
billable = _billable_input(
raw,
media_type,
allow_provider_input=allow_provider_input,
)
item = {
"type": media_type,
"role": raw.get("role") or raw.get("label") or raw.get("reference_role"),