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