feat(tenant): bill AI points by output characters
Previously AI point cost was computed from request (input) characters at reservation time. Switch to reserving a single point up front and settling the final cost from the generated output length on completion. - Reserve 1 point (or FixedPoints) instead of pricing on request chars - On completion, recompute points from output chars vs base chars, update the reservation amounts, and post a ledger delta for the top-up/refund; invalidate the workspace quota summary cache - MarkCompleted now persists final request_chars/base_chars/points - Use ceil division in calculateAIPointCost so an exact base boundary charges one point instead of rolling over - Thread output text through all CompleteAIPoints callers (article selection, KOL assist, question expansion, template assist, compliance judge) and return the settled reservation - Add unit tests plus an integration test gated on TEST_DATABASE_URL - Update the user manual: billing is by output characters Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,11 +44,20 @@ type AIPointReserveInput struct {
|
||||
type AIPointReservation struct {
|
||||
ReservationID int64
|
||||
UsageLogID int64
|
||||
OperatorID int64
|
||||
Points int
|
||||
RequestChars int
|
||||
BaseChars int
|
||||
}
|
||||
|
||||
type aiPointOutputSettlement struct {
|
||||
OutputChars int
|
||||
BaseChars int
|
||||
FinalPoints int
|
||||
ReservedPoints int
|
||||
LedgerDelta int
|
||||
}
|
||||
|
||||
func ReserveAIPoints(
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
@@ -85,8 +94,8 @@ func ReserveAIPoints(
|
||||
}
|
||||
|
||||
requestChars := countAIPointChars(input.MeteredText)
|
||||
points := calculateAIPointCost(requestChars, status.BaseChars)
|
||||
if input.FixedPoints > 0 {
|
||||
points := 1
|
||||
if input.FixedPoints > points {
|
||||
points = input.FixedPoints
|
||||
}
|
||||
if status.Total <= 0 || status.Balance < points {
|
||||
@@ -160,6 +169,7 @@ func ReserveAIPoints(
|
||||
return &AIPointReservation{
|
||||
ReservationID: reservationID,
|
||||
UsageLogID: usageID,
|
||||
OperatorID: input.OperatorID,
|
||||
Points: points,
|
||||
RequestChars: requestChars,
|
||||
BaseChars: status.BaseChars,
|
||||
@@ -169,36 +179,79 @@ func ReserveAIPoints(
|
||||
func CompleteAIPoints(
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
cache sharedcache.Cache,
|
||||
tenantID int64,
|
||||
reservation AIPointReservation,
|
||||
model string,
|
||||
) error {
|
||||
meteredOutputText string,
|
||||
) (*AIPointReservation, error) {
|
||||
completed := reservation
|
||||
if pool == nil || reservation.ReservationID <= 0 || reservation.UsageLogID <= 0 {
|
||||
return nil
|
||||
return &completed, nil
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer rollbackGenerationTx(tx)
|
||||
|
||||
pending, err := lockPendingAIPointUsage(ctx, tx, tenantID, reservation.UsageLogID)
|
||||
if err != nil || !pending {
|
||||
return err
|
||||
if err := lockTenantAIPoints(ctx, tx, tenantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := confirmAIPointReservationIfPending(ctx, tx, tenantID, reservation.ReservationID); err != nil {
|
||||
return err
|
||||
pending, err := lockPendingAIPointUsage(ctx, tx, tenantID, reservation.UsageLogID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !pending {
|
||||
return &completed, nil
|
||||
}
|
||||
|
||||
settlement := calculateAIPointOutputSettlement(reservation, meteredOutputText)
|
||||
|
||||
completed.RequestChars = settlement.OutputChars
|
||||
completed.BaseChars = settlement.BaseChars
|
||||
completed.Points = settlement.FinalPoints
|
||||
|
||||
quotaRepo := repository.NewQuotaRepository(tx)
|
||||
adjusted, err := completeAIPointReservationWithFinalAmountIfPending(ctx, tx, tenantID, reservation.ReservationID, settlement.FinalPoints)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if adjusted && settlement.LedgerDelta != 0 {
|
||||
currentBalance, err := quotaRepo.GetCurrentBalance(ctx, tenantID, aiPointsQuotaType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reason := "ai_points_output_settle"
|
||||
referenceType := "ai_point_usage"
|
||||
usageID := reservation.UsageLogID
|
||||
if _, err := quotaRepo.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||
TenantID: tenantID,
|
||||
OperatorID: reservation.OperatorID,
|
||||
QuotaType: aiPointsQuotaType,
|
||||
Delta: settlement.LedgerDelta,
|
||||
BalanceAfter: currentBalance + settlement.LedgerDelta,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
ReferenceID: &usageID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var modelPtr *string
|
||||
if strings.TrimSpace(model) != "" {
|
||||
normalized := strings.TrimSpace(model)
|
||||
modelPtr = &normalized
|
||||
}
|
||||
if err := repository.NewAIPointUsageRepository(tx).MarkCompleted(ctx, tenantID, reservation.UsageLogID, modelPtr); err != nil {
|
||||
return err
|
||||
if err := repository.NewAIPointUsageRepository(tx).MarkCompleted(ctx, tenantID, reservation.UsageLogID, settlement.OutputChars, settlement.BaseChars, settlement.FinalPoints, modelPtr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deleteCacheKey(ctx, cache, workspaceQuotaSummaryCacheKey(tenantID))
|
||||
return &completed, nil
|
||||
}
|
||||
|
||||
func RefundAIPoints(
|
||||
@@ -267,17 +320,19 @@ func RefundAIPoints(
|
||||
func CompletePendingAIPointsByResource(
|
||||
ctx context.Context,
|
||||
pool *pgxpool.Pool,
|
||||
cache sharedcache.Cache,
|
||||
tenantID int64,
|
||||
resourceType string,
|
||||
resourceID *int64,
|
||||
resourceUID *string,
|
||||
model string,
|
||||
) error {
|
||||
meteredOutputText string,
|
||||
) (*AIPointReservation, error) {
|
||||
reservation, err := findPendingAIPointReservation(ctx, pool, tenantID, resourceType, resourceID, resourceUID)
|
||||
if err != nil || reservation == nil {
|
||||
return err
|
||||
return reservation, err
|
||||
}
|
||||
return CompleteAIPoints(ctx, pool, tenantID, *reservation, model)
|
||||
return CompleteAIPoints(ctx, pool, cache, tenantID, *reservation, model, meteredOutputText)
|
||||
}
|
||||
|
||||
func RefundPendingAIPointsByResource(
|
||||
@@ -337,6 +392,7 @@ func findPendingAIPointReservationWithOperator(
|
||||
return &AIPointReservation{
|
||||
ReservationID: *log.QuotaReservationID,
|
||||
UsageLogID: log.ID,
|
||||
OperatorID: operatorID,
|
||||
Points: log.Points,
|
||||
RequestChars: log.RequestChars,
|
||||
BaseChars: log.BaseChars,
|
||||
@@ -354,7 +410,27 @@ func calculateAIPointCost(chars, baseChars int) int {
|
||||
if chars <= 0 {
|
||||
return 1
|
||||
}
|
||||
return chars/baseChars + 1
|
||||
return (chars + baseChars - 1) / baseChars
|
||||
}
|
||||
|
||||
func calculateAIPointOutputSettlement(reservation AIPointReservation, meteredOutputText string) aiPointOutputSettlement {
|
||||
baseChars := reservation.BaseChars
|
||||
if baseChars <= 0 {
|
||||
baseChars = 1000
|
||||
}
|
||||
outputChars := countAIPointChars(meteredOutputText)
|
||||
finalPoints := calculateAIPointCost(outputChars, baseChars)
|
||||
reservedPoints := reservation.Points
|
||||
if reservedPoints <= 0 {
|
||||
reservedPoints = 1
|
||||
}
|
||||
return aiPointOutputSettlement{
|
||||
OutputChars: outputChars,
|
||||
BaseChars: baseChars,
|
||||
FinalPoints: finalPoints,
|
||||
ReservedPoints: reservedPoints,
|
||||
LedgerDelta: reservedPoints - finalPoints,
|
||||
}
|
||||
}
|
||||
|
||||
func lockTenantAIPoints(ctx context.Context, tx pgx.Tx, tenantID int64) error {
|
||||
@@ -379,6 +455,30 @@ func lockPendingAIPointUsage(ctx context.Context, tx pgx.Tx, tenantID, usageLogI
|
||||
return status == "pending", nil
|
||||
}
|
||||
|
||||
func completeAIPointReservationWithFinalAmountIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64, finalPoints int) (bool, error) {
|
||||
if finalPoints <= 0 {
|
||||
finalPoints = 1
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE quota_reservations
|
||||
SET status = 'confirmed',
|
||||
reserved_amount = $4,
|
||||
consumed_amount = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND tenant_id = $2
|
||||
AND quota_type = $3
|
||||
AND status = 'pending'
|
||||
`, reservationID, tenantID, aiPointsQuotaType, finalPoints)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
return true, nil
|
||||
}
|
||||
return false, ignoreAIPointReservationReset(ctx, tx, false, tenantID, reservationID)
|
||||
}
|
||||
|
||||
func confirmAIPointReservationIfPending(ctx context.Context, tx pgx.Tx, tenantID, reservationID int64) error {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE quota_reservations
|
||||
|
||||
Reference in New Issue
Block a user