AI计价规则BUG修复
This commit is contained in:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user