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:
@@ -1139,7 +1139,7 @@ func (s *Service) ProcessReviewJob(ctx context.Context, msg ReviewJobMessage) er
|
||||
_ = s.refundComplianceLLMJudgePoints(context.Background(), job, reservation, err)
|
||||
return s.failReviewJob(ctx, job.ID, cfg.ReviewMaxAttempts, err)
|
||||
}
|
||||
if err := s.completeComplianceLLMJudgePoints(ctx, job, reservation, result.Model); err != nil && s.logger != nil {
|
||||
if err := s.completeComplianceLLMJudgePoints(ctx, job, reservation, result.Model, result.Content); err != nil && s.logger != nil {
|
||||
s.logger.Warn("compliance llm billing completion failed",
|
||||
zap.Error(err),
|
||||
zap.Int64("tenant_id", job.TenantID),
|
||||
@@ -1348,7 +1348,7 @@ func (s *Service) reserveComplianceLLMJudgePoints(ctx context.Context, job revie
|
||||
return nil, err
|
||||
}
|
||||
requestChars := countAIPointChars(meteredText)
|
||||
points := calculateAIPointCost(requestChars, status.BaseChars)
|
||||
points := 1
|
||||
if status.Total <= 0 || status.Balance < points {
|
||||
return nil, response.ErrForbidden(40381, "ai_points_insufficient", "AI points are insufficient")
|
||||
}
|
||||
@@ -1416,7 +1416,7 @@ func (s *Service) reserveComplianceLLMJudgePoints(ctx context.Context, job revie
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) completeComplianceLLMJudgePoints(ctx context.Context, job reviewJob, reservation *aiPointReservation, model string) error {
|
||||
func (s *Service) completeComplianceLLMJudgePoints(ctx context.Context, job reviewJob, reservation *aiPointReservation, model string, meteredOutputText string) error {
|
||||
if s == nil || s.pool == nil || reservation == nil || reservation.ReservationID <= 0 || reservation.UsageLogID <= 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -1425,19 +1425,59 @@ func (s *Service) completeComplianceLLMJudgePoints(ctx context.Context, job revi
|
||||
return err
|
||||
}
|
||||
defer rollbackTx(tx)
|
||||
if err := lockTenantAIPoints(ctx, tx, job.TenantID); err != nil {
|
||||
return err
|
||||
}
|
||||
pending, err := lockPendingAIPointUsage(ctx, tx, job.TenantID, reservation.UsageLogID)
|
||||
if err != nil || !pending {
|
||||
return err
|
||||
}
|
||||
if err := confirmAIPointReservationIfPending(ctx, tx, job.TenantID, reservation.ReservationID); err != nil {
|
||||
|
||||
baseChars := reservation.BaseChars
|
||||
if baseChars <= 0 {
|
||||
baseChars = 1000
|
||||
}
|
||||
outputChars := countAIPointChars(meteredOutputText)
|
||||
finalPoints := calculateAIPointCost(outputChars, baseChars)
|
||||
reservedPoints := reservation.Points
|
||||
if reservedPoints <= 0 {
|
||||
reservedPoints = 1
|
||||
}
|
||||
|
||||
quotaRepo := tenantrepo.NewQuotaRepository(tx)
|
||||
adjusted, err := completeAIPointReservationWithFinalAmountIfPending(ctx, tx, job.TenantID, reservation.ReservationID, finalPoints)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pointDelta := finalPoints - reservedPoints
|
||||
if adjusted && pointDelta != 0 {
|
||||
currentBalance, err := quotaRepo.GetCurrentBalance(ctx, job.TenantID, aiPointsQuotaType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ledgerDelta := -pointDelta
|
||||
reason := "ai_points_output_settle"
|
||||
referenceType := "ai_point_usage"
|
||||
usageID := reservation.UsageLogID
|
||||
if _, err := quotaRepo.InsertQuotaLedger(ctx, tenantrepo.QuotaLedgerInput{
|
||||
TenantID: job.TenantID,
|
||||
OperatorID: job.CheckedBy,
|
||||
QuotaType: aiPointsQuotaType,
|
||||
Delta: ledgerDelta,
|
||||
BalanceAfter: currentBalance + ledgerDelta,
|
||||
Reason: &reason,
|
||||
ReferenceType: &referenceType,
|
||||
ReferenceID: &usageID,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var modelPtr *string
|
||||
if strings.TrimSpace(model) != "" {
|
||||
normalized := strings.TrimSpace(model)
|
||||
modelPtr = &normalized
|
||||
}
|
||||
if err := tenantrepo.NewAIPointUsageRepository(tx).MarkCompleted(ctx, job.TenantID, reservation.UsageLogID, modelPtr); err != nil {
|
||||
if err := tenantrepo.NewAIPointUsageRepository(tx).MarkCompleted(ctx, job.TenantID, reservation.UsageLogID, outputChars, baseChars, finalPoints, modelPtr); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
@@ -1502,7 +1542,7 @@ func calculateAIPointCost(chars, baseChars int) int {
|
||||
if chars <= 0 {
|
||||
return 1
|
||||
}
|
||||
return chars/baseChars + 1
|
||||
return (chars + baseChars - 1) / baseChars
|
||||
}
|
||||
|
||||
func lockTenantAIPoints(ctx context.Context, tx pgx.Tx, tenantID int64) error {
|
||||
@@ -1527,6 +1567,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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package compliance
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sharedcompliance "github.com/geo-platform/tenant-api/internal/shared/compliance"
|
||||
@@ -34,3 +35,39 @@ func TestDecidePassesWithoutHits(t *testing.T) {
|
||||
t.Fatalf("decide without hits = %s, want %s", got, sharedcompliance.GateDecisionPass)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplianceAIPointCostUsesOutputBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
chars int
|
||||
baseChars int
|
||||
want int
|
||||
}{
|
||||
{name: "empty output minimum", chars: 0, baseChars: 1000, want: 1},
|
||||
{name: "single char", chars: 1, baseChars: 1000, want: 1},
|
||||
{name: "exactly one base", chars: 1000, baseChars: 1000, want: 1},
|
||||
{name: "one over base", chars: 1001, baseChars: 1000, want: 2},
|
||||
{name: "default base", chars: 2000, baseChars: 0, want: 2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := calculateAIPointCost(tt.chars, tt.baseChars); got != tt.want {
|
||||
t.Fatalf("calculateAIPointCost(%d, %d) = %d, want %d", tt.chars, tt.baseChars, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplianceAIPointCharsTrimWhitespace(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := countAIPointChars(" " + strings.Repeat("字", 1001) + "\n")
|
||||
if got != 1001 {
|
||||
t.Fatalf("countAIPointChars = %d, want 1001", got)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user