diff --git a/video-gen-api/app/api/v1/generation.py b/video-gen-api/app/api/v1/generation.py index d8941468..ab22ae98 100644 --- a/video-gen-api/app/api/v1/generation.py +++ b/video-gen-api/app/api/v1/generation.py @@ -339,6 +339,7 @@ async def optimize( attempt_no=prompt_attempt_no, charge_kind=CHARGE_TEXT_PROMPT, usage=token_usage, + media_references=record.media_references, ) await deduct_credits( db, current_user.id, text_credits, diff --git a/video-gen-api/app/services/credit_record_meta_service.py b/video-gen-api/app/services/credit_record_meta_service.py index 307866b5..84f43c33 100644 --- a/video-gen-api/app/services/credit_record_meta_service.py +++ b/video-gen-api/app/services/credit_record_meta_service.py @@ -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) diff --git a/video-gen-api/app/services/generation_billing_service.py b/video-gen-api/app/services/generation_billing_service.py index d285b734..1aab08b7 100644 --- a/video-gen-api/app/services/generation_billing_service.py +++ b/video-gen-api/app/services/generation_billing_service.py @@ -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, ) diff --git a/video-gen-api/app/services/media_token_usage_snapshot_service.py b/video-gen-api/app/services/media_token_usage_snapshot_service.py index 9167c1a8..dafffe9f 100644 --- a/video-gen-api/app/services/media_token_usage_snapshot_service.py +++ b/video-gen-api/app/services/media_token_usage_snapshot_service.py @@ -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, } ) diff --git a/video-gen-api/app/services/model_pricing/attachment_snapshot_service.py b/video-gen-api/app/services/model_pricing/attachment_snapshot_service.py index df67b6fc..05cb1e24 100644 --- a/video-gen-api/app/services/model_pricing/attachment_snapshot_service.py +++ b/video-gen-api/app/services/model_pricing/attachment_snapshot_service.py @@ -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"),