1153 lines
44 KiB
Python
1153 lines
44 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.enums.credit_record import (
|
|
CreditRecordAction,
|
|
CreditRecordBillingScene,
|
|
CreditRecordChargeKind,
|
|
CreditRecordOwnerType,
|
|
CreditRecordSourceModule,
|
|
CreditRecordSubject,
|
|
)
|
|
from app.enums.llm_billing import LlmBillingDomain, LlmBillingEvent, LlmBillingLedgerState
|
|
from app.models.credit_record import CreditRecord
|
|
from app.models.module_generation_step import ModuleGenerationStep
|
|
from app.models.token_usage import TokenUsage
|
|
from app.services.credit_record_meta_service import (
|
|
CreditRecordMeta,
|
|
build_generation_record_prompt_meta,
|
|
build_module_step_prompt_meta,
|
|
build_shot_video_analysis_meta,
|
|
)
|
|
from app.services.credits import add_credits_result, calc_text_credits, deduct_credits_result
|
|
from app.services.generation.billing_service import BillingItem, BillingSummary
|
|
from app.services.llm_billing.config import get_llm_billing_policy
|
|
from app.services.llm_billing.context import (
|
|
LlmBillingConfigurationError,
|
|
LlmBillingContext,
|
|
LlmBillingStateError,
|
|
LlmHoldResult,
|
|
LlmHoldValidation,
|
|
)
|
|
from app.services.operation_log_service import log_operation_event
|
|
from app.utils.exceptions import InsufficientCreditsError
|
|
from app.utils.id_gen import generate_id
|
|
|
|
_LEDGER_QUERY_BATCH_SIZE = 1000
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _LedgerRecords:
|
|
state: LlmBillingLedgerState
|
|
hold: CreditRecord | None = None
|
|
release: CreditRecord | None = None
|
|
charge: CreditRecord | None = None
|
|
reason: str | None = None
|
|
|
|
@property
|
|
def hold_amount(self) -> float:
|
|
return _round2(abs(float(self.hold.amount or 0))) if self.hold else 0.0
|
|
|
|
|
|
|
|
def _round2(value: Any) -> float:
|
|
try:
|
|
return round(float(value or 0), 2)
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
|
|
def _safe_int(value: Any, default: int = 0) -> int:
|
|
try:
|
|
if value is None or value == "":
|
|
return default
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
|
|
def _context_detail(ctx: LlmBillingContext, **extra: Any) -> dict[str, Any]:
|
|
detail = {
|
|
"user_id": ctx.user_id,
|
|
"owner_type": ctx.owner_type,
|
|
"owner_id": ctx.owner_id,
|
|
"attempt_no": ctx.attempt_no,
|
|
"charge_kind": ctx.charge_kind,
|
|
"billing_scene": ctx.billing_scene,
|
|
"source_module": ctx.source_module,
|
|
"source_project_id": ctx.source_project_id,
|
|
"source_step_id": ctx.source_step_id,
|
|
"source_step_code": ctx.source_step_code,
|
|
"related_id": ctx.related_id,
|
|
"hold_biz_key": ctx.hold_biz_key,
|
|
"hold_release_biz_key": ctx.hold_release_biz_key,
|
|
"charge_biz_key": ctx.charge_biz_key,
|
|
"celery_task_id": ctx.celery_task_id,
|
|
"provider": ctx.provider,
|
|
"model_name": ctx.model_name,
|
|
"token_usage_id": ctx.token_usage_id,
|
|
}
|
|
detail.update({key: value for key, value in extra.items() if value is not None})
|
|
return {key: value for key, value in detail.items() if value is not None}
|
|
|
|
|
|
|
|
def _log(
|
|
ctx: LlmBillingContext,
|
|
event: LlmBillingEvent,
|
|
*,
|
|
status: str = "success",
|
|
message: str | None = None,
|
|
detail: dict[str, Any] | None = None,
|
|
error: str | None = None,
|
|
) -> None:
|
|
log_operation_event(
|
|
domain=LlmBillingDomain.LLM_BILLING.value,
|
|
module=ctx.source_module or LlmBillingDomain.LLM_BILLING.value,
|
|
event_type=event.value,
|
|
event_status=status,
|
|
source="app.services.llm_billing.service",
|
|
trace_id=ctx.trace_id,
|
|
request_id=ctx.request_id,
|
|
user_id=ctx.user_id,
|
|
project_id=ctx.source_project_id,
|
|
task_id=ctx.owner_id,
|
|
step_id=ctx.source_step_id,
|
|
message=message,
|
|
detail=detail or _context_detail(ctx),
|
|
error=error,
|
|
)
|
|
|
|
|
|
|
|
def log_provider_start(ctx: LlmBillingContext, *, detail: Mapping[str, Any] | None = None) -> None:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.PROVIDER_START,
|
|
status="started",
|
|
detail=_context_detail(ctx, **dict(detail or {})),
|
|
)
|
|
|
|
|
|
def log_provider_success(ctx: LlmBillingContext, *, usage: Mapping[str, Any] | None = None) -> None:
|
|
usage_snapshot = dict(usage or {})
|
|
ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider
|
|
ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name
|
|
ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.PROVIDER_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
input_tokens=_safe_int(usage_snapshot.get("input_tokens")),
|
|
output_tokens=_safe_int(usage_snapshot.get("output_tokens")),
|
|
total_tokens=_safe_int(usage_snapshot.get("total_tokens")),
|
|
),
|
|
)
|
|
|
|
|
|
def log_provider_failure(ctx: LlmBillingContext, *, error: str) -> None:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.PROVIDER_FAILURE,
|
|
status="failed",
|
|
detail=_context_detail(ctx, error_type="provider_call_failed"),
|
|
error=error,
|
|
)
|
|
|
|
|
|
def log_celery_dispatch_start(ctx: LlmBillingContext) -> None:
|
|
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_START, status="started")
|
|
|
|
|
|
def log_celery_dispatch_success(ctx: LlmBillingContext) -> None:
|
|
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_SUCCESS)
|
|
|
|
|
|
def log_celery_dispatch_failure(ctx: LlmBillingContext, *, error: str) -> None:
|
|
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_FAILURE, status="failed", error=error)
|
|
|
|
|
|
def log_celery_dispatch_compensated(ctx: LlmBillingContext, *, error: str) -> None:
|
|
_log(ctx, LlmBillingEvent.CELERY_DISPATCH_COMPENSATED, detail=_context_detail(ctx, compensation_error=error))
|
|
|
|
|
|
def _hold_meta(ctx: LlmBillingContext, *, action: str) -> CreditRecordMeta:
|
|
subject = (
|
|
CreditRecordSubject.ANALYSIS.value
|
|
if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value
|
|
else CreditRecordSubject.TEXT.value
|
|
)
|
|
return CreditRecordMeta(
|
|
owner_type=ctx.owner_type,
|
|
owner_id=ctx.owner_id,
|
|
attempt_no=ctx.attempt_no,
|
|
charge_kind=ctx.charge_kind,
|
|
charge_action=action,
|
|
credit_subject=subject,
|
|
media_type="video" if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value else None,
|
|
billing_scene=ctx.billing_scene,
|
|
source_module=ctx.source_module,
|
|
source_project_id=ctx.source_project_id,
|
|
source_step_id=ctx.source_step_id,
|
|
source_step_code=ctx.source_step_code,
|
|
)
|
|
|
|
|
|
|
|
def _action_valid(record: CreditRecord | None, expected: CreditRecordAction) -> bool:
|
|
if record is None:
|
|
return True
|
|
# 兼容旧数据:正式 biz_key 已明确动作、charge_action 为空时仍可识别。
|
|
return record.charge_action in (None, expected.value)
|
|
|
|
|
|
|
|
def _record_meta_matches_context(record: CreditRecord | None, ctx: LlmBillingContext) -> bool:
|
|
if record is None:
|
|
return True
|
|
checks = (
|
|
(record.owner_type, ctx.owner_type),
|
|
(record.owner_id, ctx.owner_id),
|
|
(record.attempt_no, ctx.attempt_no),
|
|
(record.charge_kind, ctx.charge_kind),
|
|
)
|
|
return all(actual is None or str(actual) == str(expected) for actual, expected in checks)
|
|
|
|
|
|
def _classify_ledger(
|
|
*,
|
|
ctx: LlmBillingContext,
|
|
hold: CreditRecord | None,
|
|
release: CreditRecord | None,
|
|
charge: CreditRecord | None,
|
|
) -> _LedgerRecords:
|
|
if not _action_valid(hold, CreditRecordAction.HOLD):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_action_mismatch")
|
|
if not _action_valid(release, CreditRecordAction.HOLD_RELEASE):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_action_mismatch")
|
|
if not _action_valid(charge, CreditRecordAction.CHARGE):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_action_mismatch")
|
|
if not _record_meta_matches_context(hold, ctx):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_context_mismatch")
|
|
if not _record_meta_matches_context(release, ctx):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_context_mismatch")
|
|
if not _record_meta_matches_context(charge, ctx):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_context_mismatch")
|
|
if hold is None:
|
|
if release is not None or charge is not None:
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_missing_with_followup")
|
|
return _LedgerRecords(LlmBillingLedgerState.MISSING)
|
|
if release is None and charge is None:
|
|
if hold.type != "consume" or _round2(float(hold.amount or 0)) >= 0:
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_type_or_sign_invalid")
|
|
if _round2(abs(float(hold.amount or 0))) <= 0:
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "hold_amount_not_positive")
|
|
return _LedgerRecords(LlmBillingLedgerState.ACTIVE, hold)
|
|
if release is not None and charge is None:
|
|
if release.type != "refund" or _round2(float(release.amount or 0)) <= 0:
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_type_or_sign_invalid")
|
|
if str(release.refund_for_biz_key or "") != str(ctx.hold_biz_key):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_target_mismatch")
|
|
if _round2(release.amount) != _round2(abs(float(hold.amount or 0))):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_amount_mismatch")
|
|
return _LedgerRecords(LlmBillingLedgerState.RELEASED, hold, release)
|
|
if release is not None and charge is not None:
|
|
if release.type != "refund" or _round2(float(release.amount or 0)) <= 0:
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_type_or_sign_invalid")
|
|
if str(release.refund_for_biz_key or "") != str(ctx.hold_biz_key):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_target_mismatch")
|
|
if _round2(release.amount) != _round2(abs(float(hold.amount or 0))):
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "release_amount_mismatch")
|
|
if charge.type != "consume" or _round2(float(charge.amount or 0)) > 0:
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_type_or_sign_invalid")
|
|
return _LedgerRecords(LlmBillingLedgerState.CHARGED, hold, release, charge)
|
|
return _LedgerRecords(LlmBillingLedgerState.INVALID, hold, release, charge, "charge_without_release")
|
|
|
|
|
|
async def _load_ledgers(
|
|
db: AsyncSession,
|
|
contexts: Iterable[LlmBillingContext],
|
|
) -> dict[str, _LedgerRecords]:
|
|
context_list = list(contexts)
|
|
if not context_list:
|
|
return {}
|
|
record_map: dict[tuple[str, str], CreditRecord] = {}
|
|
all_keys = list(dict.fromkeys(key for ctx in context_list for key in ctx.ledger_biz_keys))
|
|
user_ids = list(dict.fromkeys(ctx.user_id for ctx in context_list))
|
|
for offset in range(0, len(all_keys), _LEDGER_QUERY_BATCH_SIZE):
|
|
chunk = all_keys[offset : offset + _LEDGER_QUERY_BATCH_SIZE]
|
|
result = await db.execute(
|
|
select(CreditRecord).where(
|
|
CreditRecord.user_id.in_(user_ids),
|
|
CreditRecord.biz_key.in_(chunk),
|
|
)
|
|
)
|
|
for record in result.scalars().all():
|
|
if record.biz_key:
|
|
record_map[(str(record.user_id), str(record.biz_key))] = record
|
|
|
|
output: dict[str, _LedgerRecords] = {}
|
|
for ctx in context_list:
|
|
hold = record_map.get((ctx.user_id, ctx.hold_biz_key))
|
|
release = record_map.get((ctx.user_id, ctx.hold_release_biz_key))
|
|
charge = record_map.get((ctx.user_id, ctx.charge_biz_key))
|
|
output[ctx.hold_biz_key] = _classify_ledger(ctx=ctx, hold=hold, release=release, charge=charge)
|
|
return output
|
|
|
|
|
|
async def _load_ledger(db: AsyncSession, ctx: LlmBillingContext) -> _LedgerRecords:
|
|
return (await _load_ledgers(db, [ctx]))[ctx.hold_biz_key]
|
|
|
|
|
|
async def get_llm_ledger_states(
|
|
db: AsyncSession,
|
|
contexts: Iterable[LlmBillingContext],
|
|
) -> dict[str, LlmHoldValidation]:
|
|
"""批量读取 attempt 的三类流水;供恢复任务收集 ID 后统一过滤。"""
|
|
context_list = list(contexts)
|
|
ledgers = await _load_ledgers(db, context_list)
|
|
return {
|
|
ctx.hold_biz_key: LlmHoldValidation(
|
|
can_execute=ledgers[ctx.hold_biz_key].state == LlmBillingLedgerState.ACTIVE,
|
|
amount=ledgers[ctx.hold_biz_key].hold_amount,
|
|
state=ledgers[ctx.hold_biz_key].state,
|
|
reason=ledgers[ctx.hold_biz_key].reason,
|
|
hold_record_id=ledgers[ctx.hold_biz_key].hold.id if ledgers[ctx.hold_biz_key].hold else None,
|
|
)
|
|
for ctx in context_list
|
|
}
|
|
|
|
|
|
async def validate_retryable_previous_attempt(
|
|
db: AsyncSession,
|
|
ctx: LlmBillingContext,
|
|
) -> LlmHoldValidation:
|
|
"""校验失败 attempt 的账务是否已关闭,供业务创建下一 attempt 前调用。
|
|
|
|
只有已释放 HOLD,或当前计费明确关闭且旧 attempt 没有流水时,才允许创建
|
|
新 attempt。这里不主动退款,避免重试入口承担失败补偿职责。
|
|
"""
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_START,
|
|
status="started",
|
|
)
|
|
ledger = await _load_ledger(db, ctx)
|
|
|
|
if ledger.state == LlmBillingLedgerState.RELEASED:
|
|
result = LlmHoldValidation(
|
|
True,
|
|
ledger.hold_amount,
|
|
ledger.state,
|
|
hold_record_id=ledger.hold.id if ledger.hold else None,
|
|
)
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
ledger_state=result.state.value,
|
|
hold_credits=result.amount,
|
|
hold_record_id=result.hold_record_id,
|
|
),
|
|
)
|
|
return result
|
|
|
|
if ledger.state == LlmBillingLedgerState.MISSING:
|
|
policy = await get_llm_billing_policy(
|
|
db,
|
|
config_key=ctx.hold_config_key,
|
|
explicit_hold_credits=None,
|
|
)
|
|
if policy.bypassed:
|
|
result = LlmHoldValidation(
|
|
True,
|
|
0.0,
|
|
LlmBillingLedgerState.BILLING_BYPASSED,
|
|
"billing_disabled",
|
|
)
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_VALIDATE_SUCCESS,
|
|
status="skipped",
|
|
detail=_context_detail(
|
|
ctx,
|
|
ledger_state=result.state.value,
|
|
billing_bypassed=True,
|
|
),
|
|
)
|
|
return result
|
|
if not policy.valid:
|
|
result = LlmHoldValidation(
|
|
False,
|
|
0.0,
|
|
LlmBillingLedgerState.INVALID,
|
|
policy.error or "billing_config_invalid",
|
|
)
|
|
else:
|
|
result = LlmHoldValidation(
|
|
False,
|
|
0.0,
|
|
ledger.state,
|
|
"previous_attempt_hold_missing",
|
|
)
|
|
else:
|
|
result = LlmHoldValidation(
|
|
False,
|
|
ledger.hold_amount,
|
|
ledger.state,
|
|
ledger.reason or f"previous_attempt_ledger_{ledger.state.value}",
|
|
ledger.hold.id if ledger.hold else None,
|
|
)
|
|
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.RETRY_PREVIOUS_ATTEMPT_BLOCKED,
|
|
status="failed",
|
|
detail=_context_detail(
|
|
ctx,
|
|
ledger_state=result.state.value,
|
|
hold_credits=result.amount,
|
|
hold_record_id=result.hold_record_id,
|
|
skip_reason=result.reason,
|
|
),
|
|
error="旧attempt账务尚未关闭,拒绝创建新的分析attempt",
|
|
)
|
|
return result
|
|
|
|
|
|
async def start_hold(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldResult:
|
|
# 幂等/异常 attempt 优先由已落库流水判定;只有全新 attempt 才读取配置。
|
|
ledger = await _load_ledger(db, ctx)
|
|
|
|
# 配置可能在任务执行期间被关闭或修改:已经存在的 active HOLD 必须继续沿用,
|
|
# 否则会留下永久冻结流水。只有“没有任何历史流水”的新 attempt 才允许按关闭配置绕过。
|
|
if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
|
|
amount = ledger.hold_amount
|
|
ctx.hold_credits = amount
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
hold_credits=amount,
|
|
hold_record_id=ledger.hold.id,
|
|
idempotent=True,
|
|
ledger_state=ledger.state.value,
|
|
),
|
|
)
|
|
return LlmHoldResult(amount, ledger.state, created=False, record_id=ledger.hold.id)
|
|
|
|
if ledger.state != LlmBillingLedgerState.MISSING:
|
|
error = f"当前attempt账务状态为{ledger.state.value},不能复用旧预扣"
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.EXECUTION_BLOCKED,
|
|
status="failed",
|
|
detail=_context_detail(ctx, ledger_state=ledger.state.value, reason=ledger.reason),
|
|
error=error,
|
|
)
|
|
raise LlmBillingStateError(error)
|
|
|
|
policy = await get_llm_billing_policy(
|
|
db,
|
|
config_key=ctx.hold_config_key,
|
|
explicit_hold_credits=ctx.hold_credits,
|
|
)
|
|
if policy.bypassed:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_BYPASSED,
|
|
status="skipped",
|
|
detail=_context_detail(ctx, ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value),
|
|
)
|
|
return LlmHoldResult(
|
|
0.0,
|
|
LlmBillingLedgerState.BILLING_BYPASSED,
|
|
reason="billing_disabled",
|
|
)
|
|
if not policy.valid:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_CONFIG_INVALID,
|
|
status="failed",
|
|
detail=_context_detail(
|
|
ctx,
|
|
hold_credits=policy.hold_credits,
|
|
config_key=policy.config_key,
|
|
config_source=policy.source_key,
|
|
),
|
|
error=policy.error,
|
|
)
|
|
raise LlmBillingConfigurationError(policy.error or "LLM计费配置无效")
|
|
|
|
amount = policy.hold_credits
|
|
ctx.hold_credits = amount
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_START,
|
|
status="started",
|
|
detail=_context_detail(
|
|
ctx,
|
|
hold_credits=amount,
|
|
config_key=policy.config_key,
|
|
config_source=policy.source_key,
|
|
),
|
|
)
|
|
try:
|
|
mutation = await deduct_credits_result(
|
|
db,
|
|
user_id=ctx.user_id,
|
|
amount=amount,
|
|
description=f"{ctx.description_prefix}预扣积分",
|
|
related_id=ctx.related_id or ctx.owner_id,
|
|
biz_key=ctx.hold_biz_key,
|
|
record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD.value),
|
|
allow_negative=False,
|
|
)
|
|
except InsufficientCreditsError:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_INSUFFICIENT,
|
|
status="failed",
|
|
detail=_context_detail(ctx, hold_credits=amount),
|
|
error="积分不足,无法预扣",
|
|
)
|
|
raise
|
|
|
|
if not mutation.created:
|
|
# 并发幂等命中后重新读取三类流水,避免复用已被另一事务释放的 HOLD。
|
|
ledger = await _load_ledger(db, ctx)
|
|
if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None:
|
|
error = f"并发预扣后账务状态为{ledger.state.value},拒绝继续执行"
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.EXECUTION_BLOCKED,
|
|
status="failed",
|
|
detail=_context_detail(ctx, ledger_state=ledger.state.value),
|
|
error=error,
|
|
)
|
|
raise LlmBillingStateError(error)
|
|
mutation_record = ledger.hold
|
|
amount = ledger.hold_amount
|
|
else:
|
|
mutation_record = mutation.record
|
|
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
hold_credits=amount,
|
|
hold_record_id=mutation_record.id if mutation_record else None,
|
|
idempotent=not mutation.created,
|
|
balance_before=mutation.balance_before,
|
|
balance_after=mutation.balance_after,
|
|
ledger_state=LlmBillingLedgerState.ACTIVE.value,
|
|
),
|
|
)
|
|
return LlmHoldResult(
|
|
amount,
|
|
LlmBillingLedgerState.ACTIVE,
|
|
created=mutation.created,
|
|
record_id=mutation_record.id if mutation_record else None,
|
|
)
|
|
|
|
|
|
async def ensure_hold_exists(db: AsyncSession, ctx: LlmBillingContext) -> LlmHoldValidation:
|
|
"""worker 调用模型前确认计费绕过或 active HOLD;不在 worker 首次预扣。"""
|
|
_log(ctx, LlmBillingEvent.EXECUTION_VALIDATE_START, status="started")
|
|
ledger = await _load_ledger(db, ctx)
|
|
|
|
# 先尊重已经落库的 attempt 账务状态,再处理当前配置。这样关闭计费不会
|
|
# 把运行中的 active HOLD 遗留为永久冻结;而新建且没有 HOLD 的任务才会绕过。
|
|
if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
|
|
amount = ledger.hold_amount
|
|
ctx.hold_credits = amount
|
|
result = LlmHoldValidation(True, amount, ledger.state, hold_record_id=ledger.hold.id)
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
hold_credits=amount,
|
|
hold_record_id=ledger.hold.id,
|
|
ledger_state=ledger.state.value,
|
|
),
|
|
)
|
|
return result
|
|
|
|
if ledger.state == LlmBillingLedgerState.MISSING:
|
|
policy = await get_llm_billing_policy(
|
|
db,
|
|
config_key=ctx.hold_config_key,
|
|
explicit_hold_credits=None,
|
|
)
|
|
else:
|
|
policy = None
|
|
if policy is not None and policy.bypassed:
|
|
result = LlmHoldValidation(
|
|
True,
|
|
0.0,
|
|
LlmBillingLedgerState.BILLING_BYPASSED,
|
|
"billing_disabled",
|
|
)
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.EXECUTION_VALIDATE_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
ledger_state=result.state.value,
|
|
billing_bypassed=True,
|
|
),
|
|
)
|
|
return result
|
|
|
|
if policy is not None and not policy.valid:
|
|
result = LlmHoldValidation(False, 0.0, LlmBillingLedgerState.INVALID, "billing_config_invalid")
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.EXECUTION_BLOCKED,
|
|
status="failed",
|
|
detail=_context_detail(ctx, ledger_state=result.state.value),
|
|
error=policy.error,
|
|
)
|
|
return result
|
|
|
|
result = LlmHoldValidation(
|
|
False,
|
|
ledger.hold_amount,
|
|
ledger.state,
|
|
ledger.reason or f"ledger_{ledger.state.value}",
|
|
ledger.hold.id if ledger.hold else None,
|
|
)
|
|
event = (
|
|
LlmBillingEvent.HOLD_MISSING
|
|
if ledger.state == LlmBillingLedgerState.MISSING
|
|
else LlmBillingEvent.EXECUTION_BLOCKED
|
|
)
|
|
_log(
|
|
ctx,
|
|
event,
|
|
status="failed",
|
|
detail=_context_detail(
|
|
ctx,
|
|
hold_credits=result.amount,
|
|
hold_record_id=result.hold_record_id,
|
|
ledger_state=result.state.value,
|
|
skip_reason=result.reason,
|
|
),
|
|
error="LLM预扣不是有效冻结状态,拒绝调用模型",
|
|
)
|
|
return result
|
|
|
|
|
|
async def _release_active_hold(
|
|
db: AsyncSession,
|
|
ctx: LlmBillingContext,
|
|
*,
|
|
hold_record: CreditRecord,
|
|
reason: str,
|
|
) -> BillingItem:
|
|
amount = _round2(abs(float(hold_record.amount or 0)))
|
|
ctx.hold_credits = amount
|
|
if amount <= 0:
|
|
_log(ctx, LlmBillingEvent.HOLD_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_record_id=hold_record.id, reason=reason, skip_reason="hold_amount_not_positive"))
|
|
return BillingItem(
|
|
charge_key=CreditRecordAction.HOLD_RELEASE.value,
|
|
amount=0.0,
|
|
charged=False,
|
|
skipped_reason="hold_amount_not_positive",
|
|
biz_key=ctx.hold_release_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
|
|
_log(ctx, LlmBillingEvent.HOLD_RELEASE_START, status="started", detail=_context_detail(ctx, hold_credits=amount, hold_record_id=hold_record.id, reason=reason))
|
|
mutation = await add_credits_result(
|
|
db,
|
|
user_id=ctx.user_id,
|
|
amount=amount,
|
|
description=f"{ctx.description_prefix}预扣积分释放",
|
|
related_id=ctx.related_id or ctx.owner_id,
|
|
record_type="refund",
|
|
biz_key=ctx.hold_release_biz_key,
|
|
refund_for_biz_key=ctx.hold_biz_key,
|
|
record_meta=_hold_meta(ctx, action=CreditRecordAction.HOLD_RELEASE.value),
|
|
)
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_RELEASE_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
hold_credits=amount,
|
|
hold_record_id=hold_record.id,
|
|
hold_release_record_id=mutation.record.id if mutation.record else None,
|
|
reason=reason,
|
|
idempotent=not mutation.created,
|
|
balance_before=mutation.balance_before,
|
|
balance_after=mutation.balance_after,
|
|
),
|
|
)
|
|
return BillingItem(
|
|
charge_key=CreditRecordAction.HOLD_RELEASE.value,
|
|
amount=amount,
|
|
charged=False,
|
|
skipped_reason=None if mutation.created else "already_released",
|
|
biz_key=ctx.hold_release_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
|
|
|
|
async def release_hold(db: AsyncSession, ctx: LlmBillingContext, *, reason: str = "failure") -> BillingItem:
|
|
ledger = await _load_ledger(db, ctx)
|
|
|
|
# 即使管理员已经关闭计费,历史 active HOLD 也必须按真实冻结流水释放。
|
|
if ledger.state == LlmBillingLedgerState.ACTIVE and ledger.hold:
|
|
return await _release_active_hold(db, ctx, hold_record=ledger.hold, reason=reason)
|
|
if ledger.state in (LlmBillingLedgerState.RELEASED, LlmBillingLedgerState.CHARGED):
|
|
amount = _round2(abs(float(ledger.release.amount or 0))) if ledger.release else ledger.hold_amount
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_RELEASE_SKIPPED,
|
|
status="skipped",
|
|
detail=_context_detail(
|
|
ctx,
|
|
reason=reason,
|
|
hold_credits=amount,
|
|
ledger_state=ledger.state.value,
|
|
skip_reason="already_released",
|
|
idempotent=True,
|
|
),
|
|
)
|
|
return BillingItem(
|
|
charge_key=CreditRecordAction.HOLD_RELEASE.value,
|
|
amount=amount,
|
|
charged=False,
|
|
skipped_reason="already_released",
|
|
biz_key=ctx.hold_release_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
if ledger.state == LlmBillingLedgerState.MISSING:
|
|
policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key)
|
|
else:
|
|
policy = None
|
|
if policy is not None and policy.bypassed:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_RELEASE_SKIPPED,
|
|
status="skipped",
|
|
detail=_context_detail(
|
|
ctx,
|
|
reason=reason,
|
|
ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value,
|
|
skip_reason="billing_disabled",
|
|
),
|
|
)
|
|
return BillingItem(
|
|
charge_key=CreditRecordAction.HOLD_RELEASE.value,
|
|
amount=0.0,
|
|
charged=False,
|
|
skipped_reason="billing_disabled",
|
|
biz_key=ctx.hold_release_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.HOLD_RELEASE_SKIPPED,
|
|
status="skipped",
|
|
detail=_context_detail(
|
|
ctx,
|
|
reason=reason,
|
|
ledger_state=ledger.state.value,
|
|
skip_reason=ledger.reason or ledger.state.value,
|
|
),
|
|
error="没有可释放的有效LLM预扣流水",
|
|
)
|
|
return BillingItem(
|
|
charge_key=CreditRecordAction.HOLD_RELEASE.value,
|
|
amount=0.0,
|
|
charged=False,
|
|
skipped_reason=ledger.reason or ledger.state.value,
|
|
biz_key=ctx.hold_release_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
|
|
|
|
async def release_on_failure(db: AsyncSession, ctx: LlmBillingContext, *, error: str | None = None) -> BillingSummary:
|
|
_log(ctx, LlmBillingEvent.FAILURE_RELEASE_START, status="started", detail=_context_detail(ctx, error=error), error=error)
|
|
item = await release_hold(db, ctx, reason="failure")
|
|
if item.amount > 0 and not item.skipped_reason:
|
|
_log(ctx, LlmBillingEvent.FAILURE_RELEASE_SUCCESS, detail=_context_detail(ctx, hold_credits=item.amount, error=error), error=error)
|
|
else:
|
|
_log(ctx, LlmBillingEvent.FAILURE_RELEASE_SKIPPED, status="skipped", detail=_context_detail(ctx, hold_credits=item.amount, error=error, skip_reason=item.skipped_reason or "hold_not_active"), error=error)
|
|
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[item])
|
|
|
|
|
|
def _normalize_settlement_usage(
|
|
ctx: LlmBillingContext,
|
|
usage: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
snapshot = dict(usage or {})
|
|
try:
|
|
if snapshot.get("usage_reported") is False and not bool(snapshot.get("billing_free")):
|
|
raise ValueError("provider_usage_missing")
|
|
if "input_tokens" not in snapshot or "output_tokens" not in snapshot:
|
|
raise ValueError("input_or_output_tokens_missing")
|
|
input_tokens = int(snapshot.get("input_tokens"))
|
|
output_tokens = int(snapshot.get("output_tokens"))
|
|
if input_tokens < 0 or output_tokens < 0:
|
|
raise ValueError("token_count_negative")
|
|
normalized_total = input_tokens + output_tokens
|
|
raw_total = snapshot.get("total_tokens")
|
|
if raw_total not in (None, ""):
|
|
total_tokens = int(raw_total)
|
|
if total_tokens < 0:
|
|
raise ValueError("total_tokens_negative")
|
|
else:
|
|
total_tokens = normalized_total
|
|
if total_tokens != normalized_total:
|
|
snapshot["reported_total_tokens"] = total_tokens
|
|
total_tokens = normalized_total
|
|
snapshot["input_tokens"] = input_tokens
|
|
snapshot["output_tokens"] = output_tokens
|
|
snapshot["total_tokens"] = total_tokens
|
|
return snapshot
|
|
except (TypeError, ValueError) as exc:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.USAGE_INVALID,
|
|
status="failed",
|
|
detail=_context_detail(ctx, usage=snapshot, reason=str(exc)),
|
|
error="LLM usage 无效,拒绝释放 HOLD 和创建真实扣费",
|
|
)
|
|
raise LlmBillingStateError(f"LLM usage 无效:{exc}") from exc
|
|
|
|
|
|
async def _ensure_token_usage_once(
|
|
db: AsyncSession,
|
|
ctx: LlmBillingContext,
|
|
usage: dict[str, Any],
|
|
) -> TokenUsage:
|
|
supplied_id = str(usage.get("token_usage_id") or "").strip() or None
|
|
if supplied_id:
|
|
supplied_result = await db.execute(
|
|
select(TokenUsage).where(TokenUsage.id == supplied_id).limit(1)
|
|
)
|
|
supplied = supplied_result.scalar_one_or_none()
|
|
if supplied is not None:
|
|
if supplied.user_id not in (None, ctx.user_id):
|
|
raise LlmBillingStateError("TokenUsage 用户归属与当前账务上下文不一致")
|
|
if supplied.biz_key not in (None, ctx.charge_biz_key):
|
|
raise LlmBillingStateError("TokenUsage biz_key 与当前 charge 不一致")
|
|
supplied.user_id = supplied.user_id or ctx.user_id
|
|
supplied.owner_type = supplied.owner_type or ctx.owner_type
|
|
supplied.owner_id = supplied.owner_id or ctx.owner_id
|
|
supplied.biz_key = supplied.biz_key or ctx.charge_biz_key
|
|
supplied.source_module = supplied.source_module or ctx.source_module
|
|
supplied.source_step_code = supplied.source_step_code or ctx.source_step_code
|
|
usage["token_usage_id"] = supplied.id
|
|
ctx.token_usage_id = supplied.id
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.TOKEN_USAGE_REUSED,
|
|
detail=_context_detail(ctx, token_usage_id=supplied.id, source="supplied_id"),
|
|
)
|
|
return supplied
|
|
|
|
result = await db.execute(
|
|
select(TokenUsage)
|
|
.where(
|
|
TokenUsage.user_id == ctx.user_id,
|
|
TokenUsage.biz_key == ctx.charge_biz_key,
|
|
)
|
|
.limit(1)
|
|
)
|
|
existing = result.scalar_one_or_none()
|
|
if existing is not None:
|
|
usage["token_usage_id"] = existing.id
|
|
ctx.token_usage_id = existing.id
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.TOKEN_USAGE_REUSED,
|
|
detail=_context_detail(ctx, token_usage_id=existing.id, source="biz_key"),
|
|
)
|
|
return existing
|
|
|
|
token_usage = TokenUsage(
|
|
id=generate_id(),
|
|
model_config_id=usage.get("model_config_id"),
|
|
user_id=ctx.user_id,
|
|
input_tokens=int(usage["input_tokens"]),
|
|
output_tokens=int(usage["output_tokens"]),
|
|
total_tokens=int(usage["total_tokens"]),
|
|
owner_type=ctx.owner_type,
|
|
owner_id=ctx.owner_id,
|
|
biz_key=ctx.charge_biz_key,
|
|
source_module=ctx.source_module,
|
|
source_step_code=ctx.source_step_code,
|
|
)
|
|
try:
|
|
async with db.begin_nested():
|
|
db.add(token_usage)
|
|
await db.flush()
|
|
except IntegrityError:
|
|
if token_usage in db.sync_session:
|
|
db.sync_session.expunge(token_usage)
|
|
result = await db.execute(
|
|
select(TokenUsage)
|
|
.where(
|
|
TokenUsage.user_id == ctx.user_id,
|
|
TokenUsage.biz_key == ctx.charge_biz_key,
|
|
)
|
|
.limit(1)
|
|
)
|
|
token_usage = result.scalar_one_or_none()
|
|
if token_usage is None:
|
|
raise
|
|
event = LlmBillingEvent.TOKEN_USAGE_REUSED
|
|
else:
|
|
event = LlmBillingEvent.TOKEN_USAGE_CREATED
|
|
|
|
usage["token_usage_id"] = token_usage.id
|
|
ctx.token_usage_id = token_usage.id
|
|
_log(
|
|
ctx,
|
|
event,
|
|
detail=_context_detail(
|
|
ctx,
|
|
token_usage_id=token_usage.id,
|
|
input_tokens=token_usage.input_tokens,
|
|
output_tokens=token_usage.output_tokens,
|
|
total_tokens=token_usage.total_tokens,
|
|
),
|
|
)
|
|
return token_usage
|
|
|
|
|
|
async def _build_charge_meta(db: AsyncSession, ctx: LlmBillingContext, usage: Mapping[str, Any]) -> CreditRecordMeta:
|
|
usage_snapshot = dict(usage or {})
|
|
ctx.provider = str(usage_snapshot.get("provider") or usage_snapshot.get("model_provider") or "") or ctx.provider
|
|
ctx.model_name = str(usage_snapshot.get("model_name") or usage_snapshot.get("model") or "") or ctx.model_name
|
|
ctx.token_usage_id = str(usage_snapshot.get("token_usage_id") or "") or ctx.token_usage_id
|
|
if ctx.owner_type == CreditRecordOwnerType.GENERATION_RECORD.value:
|
|
return await build_generation_record_prompt_meta(
|
|
db,
|
|
record_id=ctx.owner_id,
|
|
attempt_no=ctx.attempt_no,
|
|
charge_kind=ctx.charge_kind,
|
|
usage=usage_snapshot,
|
|
)
|
|
if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value:
|
|
return await build_module_step_prompt_meta(
|
|
db,
|
|
step_id=ctx.owner_id,
|
|
attempt_no=ctx.attempt_no,
|
|
usage=usage_snapshot,
|
|
)
|
|
if ctx.charge_kind == CreditRecordChargeKind.VIDEO_ANALYSIS.value:
|
|
return await build_shot_video_analysis_meta(
|
|
db,
|
|
owner_type=ctx.owner_type,
|
|
owner_id=ctx.owner_id,
|
|
attempt_no=ctx.attempt_no,
|
|
usage=usage_snapshot,
|
|
billing_scene=ctx.billing_scene or CreditRecordBillingScene.SHOT_VIDEO_ANALYSIS.value,
|
|
source_project_id=ctx.source_project_id,
|
|
source_step_id=ctx.source_step_id,
|
|
)
|
|
return CreditRecordMeta(
|
|
owner_type=ctx.owner_type,
|
|
owner_id=ctx.owner_id,
|
|
attempt_no=ctx.attempt_no,
|
|
charge_kind=ctx.charge_kind,
|
|
charge_action=CreditRecordAction.CHARGE.value,
|
|
credit_subject=CreditRecordSubject.TEXT.value,
|
|
billing_scene=ctx.billing_scene,
|
|
source_module=ctx.source_module,
|
|
source_project_id=ctx.source_project_id,
|
|
source_step_id=ctx.source_step_id,
|
|
source_step_code=ctx.source_step_code,
|
|
token_usage_id=usage_snapshot.get("token_usage_id"),
|
|
input_tokens=_safe_int(usage_snapshot.get("input_tokens")),
|
|
output_tokens=_safe_int(usage_snapshot.get("output_tokens")),
|
|
total_tokens=_safe_int(usage_snapshot.get("total_tokens")),
|
|
)
|
|
|
|
|
|
async def _settle_success_impl(
|
|
db: AsyncSession,
|
|
ctx: LlmBillingContext,
|
|
*,
|
|
usage: Mapping[str, Any],
|
|
description: str | None = None,
|
|
) -> BillingSummary:
|
|
ledger = await _load_ledger(db, ctx)
|
|
|
|
if ledger.state == LlmBillingLedgerState.CHARGED and ledger.hold and ledger.release and ledger.charge:
|
|
release_item = BillingItem(
|
|
charge_key=CreditRecordAction.HOLD_RELEASE.value,
|
|
amount=abs(_round2(ledger.release.amount)),
|
|
charged=False,
|
|
skipped_reason="already_released",
|
|
biz_key=ctx.hold_release_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
charge_item = BillingItem(
|
|
charge_key=ctx.charge_kind,
|
|
amount=abs(_round2(ledger.charge.amount)),
|
|
charged=False,
|
|
skipped_reason="already_charged",
|
|
biz_key=ctx.charge_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.SETTLE_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
actual_credits=charge_item.amount,
|
|
ledger_state=ledger.state.value,
|
|
idempotent=True,
|
|
),
|
|
)
|
|
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item])
|
|
|
|
# 新任务在关闭计费时没有 HOLD,成功后直接绕过;历史 active HOLD 则必须继续结算。
|
|
if ledger.state == LlmBillingLedgerState.MISSING:
|
|
policy = await get_llm_billing_policy(db, config_key=ctx.hold_config_key)
|
|
else:
|
|
policy = None
|
|
if policy is not None and policy.bypassed:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.SETTLE_SUCCESS,
|
|
status="skipped",
|
|
detail=_context_detail(
|
|
ctx,
|
|
ledger_state=LlmBillingLedgerState.BILLING_BYPASSED.value,
|
|
billing_bypassed=True,
|
|
),
|
|
)
|
|
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[])
|
|
|
|
if ledger.state != LlmBillingLedgerState.ACTIVE or ledger.hold is None:
|
|
# 统一由 settle_success 外层记录一次 SETTLE_FAILED,避免同一异常产生重复日志。
|
|
raise LlmBillingStateError(
|
|
f"当前attempt账务状态为{ledger.state.value},不能执行成功结算"
|
|
)
|
|
|
|
normalized_usage = _normalize_settlement_usage(ctx, usage)
|
|
await _ensure_token_usage_once(db, ctx, normalized_usage)
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.SETTLE_START,
|
|
status="started",
|
|
detail=_context_detail(
|
|
ctx,
|
|
ledger_state=ledger.state.value,
|
|
input_tokens=normalized_usage["input_tokens"],
|
|
output_tokens=normalized_usage["output_tokens"],
|
|
total_tokens=normalized_usage["total_tokens"],
|
|
provider=normalized_usage.get("provider") or normalized_usage.get("model_provider"),
|
|
model_name=normalized_usage.get("model_name") or normalized_usage.get("model"),
|
|
),
|
|
)
|
|
input_tokens = int(normalized_usage["input_tokens"])
|
|
output_tokens = int(normalized_usage["output_tokens"])
|
|
amount = await calc_text_credits(db, input_tokens, output_tokens)
|
|
meta = await _build_charge_meta(db, ctx, normalized_usage)
|
|
release_item = await _release_active_hold(db, ctx, hold_record=ledger.hold, reason="success")
|
|
if meta.charge_action is None:
|
|
meta.charge_action = CreditRecordAction.CHARGE.value
|
|
meta.billing_scene = meta.billing_scene or ctx.billing_scene
|
|
meta.source_module = meta.source_module or ctx.source_module
|
|
meta.source_project_id = meta.source_project_id or ctx.source_project_id
|
|
meta.source_step_id = meta.source_step_id or ctx.source_step_id
|
|
meta.source_step_code = meta.source_step_code or ctx.source_step_code
|
|
|
|
mutation = await deduct_credits_result(
|
|
db,
|
|
user_id=ctx.user_id,
|
|
amount=amount,
|
|
description=description or f"{ctx.description_prefix}真实扣费",
|
|
related_id=ctx.related_id or ctx.owner_id,
|
|
biz_key=ctx.charge_biz_key,
|
|
record_meta=meta,
|
|
allow_negative=True,
|
|
create_zero_record=True,
|
|
)
|
|
charged_amount = mutation.amount
|
|
charge_item = BillingItem(
|
|
charge_key=ctx.charge_kind,
|
|
amount=charged_amount,
|
|
charged=mutation.created,
|
|
skipped_reason=None if mutation.created else "already_charged",
|
|
biz_key=ctx.charge_biz_key,
|
|
attempt_no=ctx.attempt_no,
|
|
)
|
|
|
|
if ctx.owner_type == CreditRecordOwnerType.MODULE_GENERATION_STEP.value:
|
|
result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == ctx.owner_id).limit(1))
|
|
step = result.scalar_one_or_none()
|
|
if step:
|
|
step.token_usage_id = meta.token_usage_id
|
|
step.model_config_id = normalized_usage.get("model_config_id")
|
|
step.input_tokens = meta.input_tokens
|
|
step.output_tokens = meta.output_tokens
|
|
step.total_tokens = meta.total_tokens
|
|
step.text_credits_cost = charged_amount
|
|
|
|
after_balance = mutation.balance_after
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.CHARGE_SUCCESS,
|
|
detail=_context_detail(
|
|
ctx,
|
|
actual_credits=charged_amount,
|
|
charge_record_id=mutation.record.id if mutation.record else None,
|
|
idempotent=not mutation.created,
|
|
balance_before=mutation.balance_before,
|
|
balance_after=after_balance,
|
|
allow_negative=True,
|
|
),
|
|
)
|
|
if after_balance < 0:
|
|
_log(ctx, LlmBillingEvent.CHARGE_NEGATIVE_BALANCE, status="warning", detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, allow_negative=True))
|
|
_log(ctx, LlmBillingEvent.SETTLE_SUCCESS, detail=_context_detail(ctx, actual_credits=charged_amount, balance_after=after_balance, ledger_state=LlmBillingLedgerState.CHARGED.value, idempotent=not mutation.created))
|
|
return BillingSummary(record_id=ctx.owner_id, user_id=ctx.user_id, items=[release_item, charge_item])
|
|
|
|
async def settle_success(
|
|
db: AsyncSession,
|
|
ctx: LlmBillingContext,
|
|
*,
|
|
usage: Mapping[str, Any],
|
|
description: str | None = None,
|
|
) -> BillingSummary:
|
|
"""成功结算统一入口;任何异常都留下可检索的 SETTLE_FAILED 日志。"""
|
|
try:
|
|
return await _settle_success_impl(
|
|
db,
|
|
ctx,
|
|
usage=usage,
|
|
description=description,
|
|
)
|
|
except Exception as exc:
|
|
_log(
|
|
ctx,
|
|
LlmBillingEvent.SETTLE_FAILED,
|
|
status="failed",
|
|
detail=_context_detail(ctx, error_type=type(exc).__name__),
|
|
error=str(exc),
|
|
)
|
|
raise
|