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
+1
View File
@@ -339,6 +339,7 @@ async def optimize(
attempt_no=prompt_attempt_no, attempt_no=prompt_attempt_no,
charge_kind=CHARGE_TEXT_PROMPT, charge_kind=CHARGE_TEXT_PROMPT,
usage=token_usage, usage=token_usage,
media_references=record.media_references,
) )
await deduct_credits( await deduct_credits(
db, current_user.id, text_credits, db, current_user.id, text_credits,
@@ -116,6 +116,28 @@ def _normalize_frontend_kind(value: str | None) -> str:
return value or FrontendUserKind.EXTERNAL.value 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: 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)) result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@@ -286,6 +308,7 @@ async def build_generation_media_meta(
inference_mode: str | None = None, inference_mode: str | None = None,
input_video_duration: float | None = None, input_video_duration: float | None = None,
requested_output_count: int = 1, requested_output_count: int = 1,
provider_uses_media_references: bool | None = None,
) -> CreditRecordMeta: ) -> CreditRecordMeta:
media_type = (gen_type or "").lower().strip() or None media_type = (gen_type or "").lower().strip() or None
if source_module is 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(): for key, value in (await get_engine_snapshot(db, gen_type=media_type or "", engine_id=engine_id)).items():
setattr(meta, key, value) setattr(meta, key, value)
requested_count = max(1, _safe_int(requested_output_count, 1)) requested_count = max(1, _safe_int(requested_output_count, 1))
attachment_snapshot, attachment_counts = build_attachment_snapshot(media_references) if provider_uses_media_references is None:
for key, value in attachment_counts.items(): # GenerationRecord 的附件只参与前置提示词优化,媒体供应商调用明确不再携带;
setattr(meta, key, value) # 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 meta.attachment_snapshot_json = attachment_snapshot
width, height = parse_dimensions(image_px, image_size) width, height = parse_dimensions(image_px, image_size)
@@ -340,6 +369,16 @@ async def build_generation_media_meta(
"inference_mode": inference_mode or "online", "inference_mode": inference_mode or "online",
"stage": "request_locked", "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 = { request_usage = {
"resolution": str(resolution or "").lower(), "resolution": str(resolution or "").lower(),
"aspect_ratio": str(aspect_ratio or ""), "aspect_ratio": str(aspect_ratio or ""),
@@ -347,11 +386,13 @@ async def build_generation_media_meta(
"output_height": height, "output_height": height,
"dimension_source": dimension_source, "dimension_source": dimension_source,
"output_video_duration_seconds": float(duration or 0), "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_video_duration_seconds": provider_input_video_duration,
"input_audio_duration_seconds": float(attachment_counts["attachment_audio_duration_seconds"] or 0), "input_audio_duration_seconds": provider_input_audio_duration,
"provider_input_image_count": attachment_counts.get("provider_input_image_count", 0), "provider_input_image_count": provider_input_image_count,
"input_image_count": attachment_counts.get("provider_input_image_count", 0), "provider_input_video_count": provider_input_video_count,
"has_input_video": bool(attachment_counts.get("provider_input_video_count", 0) or input_video_duration), "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, "requested_output_count": requested_count,
"successful_output_count": 0, "successful_output_count": 0,
"fps": float(fps or 0), "fps": float(fps or 0),
@@ -369,6 +410,7 @@ async def build_generation_record_prompt_meta(
attempt_no: int, attempt_no: int,
charge_kind: str, charge_kind: str,
usage: Mapping[str, Any], usage: Mapping[str, Any],
media_references: Any = None,
) -> CreditRecordMeta: ) -> CreditRecordMeta:
scene_map = { scene_map = {
CreditRecordChargeKind.TEXT_PROMPT.value: CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value, 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"))), 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) 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) 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, attempt_no=attempt_no,
charge_kind=CHARGE_TEXT_PROMPT, charge_kind=CHARGE_TEXT_PROMPT,
usage=usage, usage=usage,
media_references=record.media_references,
) )
items.append( items.append(
await deduct_credits_locked_once( await deduct_credits_locked_once(
@@ -270,6 +271,7 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no, attempt_no=attempt_no,
charge_kind=CHARGE_FILE_PARSE, charge_kind=CHARGE_FILE_PARSE,
usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0}, 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( items.append(
await deduct_credits_locked_once( await deduct_credits_locked_once(
@@ -299,6 +301,7 @@ async def charge_chatapi_prompt_usage(
attempt_no=attempt_no, attempt_no=attempt_no,
charge_kind=CHARGE_VISION_INPUT, charge_kind=CHARGE_VISION_INPUT,
usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0}, 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( items.append(
await deduct_credits_locked_once( await deduct_credits_locked_once(
@@ -477,6 +480,7 @@ async def charge_generation_media_by_params(
billing_scene: str | None = None, billing_scene: str | None = None,
media_references: Any = None, media_references: Any = None,
requested_output_count: int = 1, requested_output_count: int = 1,
provider_uses_media_references: bool | None = None,
) -> BillingSummary: ) -> BillingSummary:
"""图片/视频媒体生成扣费。 """图片/视频媒体生成扣费。
@@ -522,6 +526,7 @@ async def charge_generation_media_by_params(
inference_mode=inference_mode, inference_mode=inference_mode,
input_video_duration=input_video_duration, input_video_duration=input_video_duration,
requested_output_count=requested_output_count, requested_output_count=requested_output_count,
provider_uses_media_references=provider_uses_media_references,
) )
if gen_type == "image": if gen_type == "image":
@@ -589,4 +594,5 @@ async def charge_generation_media_for_record(
attempt_no=attempt_no, attempt_no=attempt_no,
source_module=CreditRecordSourceModule.GENERATION_RECORD.value, source_module=CreditRecordSourceModule.GENERATION_RECORD.value,
media_references=record.media_references, 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.attachment_snapshot_service import build_attachment_snapshot, build_generation_snapshot
from app.services.model_pricing.usage_normalizer import ( from app.services.model_pricing.usage_normalizer import (
normalize_provider_media_usage, normalize_provider_media_usage,
safe_float,
safe_int, safe_int,
safe_json_dict, safe_json_dict,
) )
@@ -150,35 +151,75 @@ async def _sync_charge_snapshot(
return None return None
response = provider_response if provider_response is not None else getattr(owner, "provider_response_json", 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( generation_snapshot, generation_counts, generation_usage = build_generation_snapshot(
owner, owner,
provider_response=response, provider_response=response,
stage=stage, stage=stage,
) )
existing_usage = deepcopy(dict(charge.usage_snapshot_json or {})) 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( provider_usage = normalize_provider_media_usage(
response, response,
gen_type=gen_type, gen_type=gen_type,
fallback_total_tokens=fallback_total, fallback_total_tokens=fallback_total,
request_image_px=getattr(owner, "image_px", None), request_image_px=getattr(owner, "image_px", None),
requested_output_count=max(1, safe_int(generation_counts.get("requested_output_count"), 1)), 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} 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( usage.update(
{ {
"has_input_video": safe_int(attachment_counts.get("provider_input_video_count")) > 0, "has_input_video": bool(provider_input_video_count or input_video_duration_seconds),
"provider_input_image_count": safe_int( "provider_input_image_count": provider_input_image_count,
provider_usage.get("provider_input_image_count"), "provider_input_video_count": provider_input_video_count,
safe_int(attachment_counts.get("provider_input_image_count")), "provider_input_audio_count": provider_input_audio_count,
), "input_image_count": provider_input_image_count,
"input_image_count": safe_int( "input_video_duration_seconds": input_video_duration_seconds,
provider_usage.get("provider_input_image_count"), "input_audio_duration_seconds": input_audio_duration_seconds,
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),
"usage_stage": stage, "usage_stage": stage,
} }
) )
@@ -80,7 +80,15 @@ def _walk_reference_items(value: Any) -> Iterable[Mapping[str, Any]]:
yield from _walk_reference_items(child) 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: if "billable_input" in raw:
return safe_bool(raw.get("billable_input"), True) return safe_bool(raw.get("billable_input"), True)
role = str(raw.get("role") or raw.get("label") or raw.get("reference_role") or "").lower() 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 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]] = [] items: list[dict[str, Any]] = []
image_count = video_count = audio_count = 0 image_count = video_count = audio_count = 0
provider_input_image_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) seen.add(dedupe_key)
duration = max(0.0, safe_float(raw.get("duration"), safe_float(raw.get("duration_seconds")))) 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 = { item = {
"type": media_type, "type": media_type,
"role": raw.get("role") or raw.get("label") or raw.get("reference_role"), "role": raw.get("role") or raw.get("label") or raw.get("reference_role"),