diff --git a/AGENTS.md b/AGENTS.md index 209f97c..e510277 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Token Pilot is evolving from a Spring AI usage-tracking starter into a framework-independent Java LLM control and accounting core with optional framework and observability adapters. -Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, pure budget decisions, typed missing-pricing policies, pricing snapshots, framework-independent token count results, a UTF-8 byte heuristic estimator, a preflight cost-bound projection, versioned model metadata, conservative context admission, a plain-Java core consumer verification path, and the framework-independent in-memory atomic reservation/idempotency foundation are implemented. Candidate-aware request production and estimate/actual cost reconciliation are 30-day MVP targets, not current capabilities. +Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, pure budget decisions, typed missing-pricing policies, pricing snapshots, framework-independent token count results, a UTF-8 byte heuristic estimator, a preflight cost-bound projection, versioned model metadata, conservative context admission, a plain-Java core consumer verification path, framework-independent in-memory atomic reservations, and estimate/actual reconciliation with best-effort accounting events are implemented. Candidate-aware request production and Spring AI lifecycle integration remain 30-day MVP targets, not current capabilities. Distribution direction: publish a framework-independent core and an optional Spring AI convenience starter from the same repository and release train. The existing starter artifact is `token-pilot-starter`; `token-pilot-spring-ai-starter` is only a target name until a compatibility ADR and module change land. @@ -74,7 +74,7 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac | `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager, pricing snapshots, versioned model catalog, token count results, UTF-8 byte heuristic estimation, preflight cost-bound projection, conservative context admission, and public plain-Java consumer verification | | `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, pricing snapshot resolution, response usage recording, reconciliation decisions, and legacy provider-boundary BLOCK enforcement | | `token-pilot-micrometer` | Basic implementation complete | `MetricsOptions`, tag whitelist, and metric metadata exist; metric ownership must be narrowed | -| `token-pilot-budget` | Basic atomic reservation implementation | Typed monthly keys, Clock/ZoneId windows, pure status/admission decisions, safe-upper-bound reservations, bucket-scoped atomicity, idempotency, and framework-independent snapshots implemented; candidate production, lifecycle reconciliation, and durable stores remain | +| `token-pilot-budget` | Atomic reservation and reconciliation implemented | Typed monthly keys, Clock/ZoneId windows, safe-upper-bound reservations, commit/release/write-off lifecycle, pending reconciliation liability, estimate/actual token and cost deltas, duplicate callback protection, and framework-independent best-effort accounting events implemented; candidate production and durable stores remain | | `token-pilot-notification` | Basic implementation complete | Event API and deduplication exist; not yet connected to the full advisor/budget lifecycle | | `token-pilot-autoconfigure` | Basic implementation complete | Bean registration, property binding, pricing/budget/notification wiring, and `ChatClientBuilderCustomizer` implemented | | `token-pilot-starter` | Basic implementation complete | Thin final user entrypoint that brings runtime modules together | @@ -329,6 +329,8 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works - The legacy `DefaultLedgerManager.record(String, ...)` path preserves an explicit zero USD fail-open result for a missing plan; the pricing-snapshot path applies `MissingPricingPolicy` and records `UNPRICED` or rejects before provider invocation, so neither behavior is a priced zero-rate plan. - Spring AI usage extraction converts map/JSON-compatible native usage objects into the normalized core model. Real-provider compatibility fixtures remain required because provider and Spring AI usage shapes can change independently. - The legacy provider boundary blocks an already-exhausted budget decision before provider invocation. Its candidate-free `STATUS` input is a regression guard, not admission evidence; the flow remains check-then-add and is not connected to the new atomic reservation lifecycle until #39. +- In-memory reservation reconciliation uses the reservation-time pricing snapshot, moves estimate liability atomically between active, pending, and committed totals, and skips cost calculation for exact duplicate callbacks. Spring AI callback integration remains #39. +- Accounting listeners run synchronously after the bucket lock is released. Runtime listener failures do not roll back a committed transition, stop later listeners, or trigger redelivery on duplicate callbacks, but delivery remains best-effort at-most-once without a durable outbox; failure observation remains #40. - Current Micrometer `ai.token.*` metrics may duplicate Spring AI Observability; preserve compatibility while deciding default suppression or replacement. - The verified Spring AI 2.0.0 path is synchronous `ChatClient` usage recording with a fake provider. Streaming cancellation and reconciliation remain outside the current compatibility guarantee. - The repository, README, JReleaser configuration, and every published module POM use the MIT License. `verifyPublicationMetadata` guards this release contract and ensures the sample app is not published. @@ -400,6 +402,11 @@ Stage and deploy a Central release: ## Update History +### 2026-08-20 + +- Added the reservation accounting lifecycle for dispatch, commit, release, unresolved actual usage, late actual reconciliation, and write-off with bucket-scoped atomic liability movement and idempotent terminal outcomes. +- Added estimate/actual token and cost deltas, over-limit results, bounded accounting reasons, exact callback fingerprinting, and framework-independent accounting events delivered best-effort at most once without rolling back successful transitions on runtime listener failures. + ### 2026-08-15 - Added the framework-independent `BudgetStateStore` atomic safe-upper-bound reservation contract with immutable reservation IDs, idempotency fingerprints, bucket-scoped concurrency control, currency-safe outcomes, and effective-usage snapshots. diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java new file mode 100644 index 0000000..00df53c --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/AccountingTransitionStatus.java @@ -0,0 +1,36 @@ +package io.tokenpilot.budget; + +/** + * 예약 생성 이후 회계 상태 변경 명령의 결과입니다. + * + *

{@link #APPLIED}만 새로운 회계 변경이 적용되었음을 뜻합니다. 나머지 결과는 기존 상태와 + * 금액을 바꾸지 않았음을 뜻하므로 호출자는 예외 메시지나 저장소 내부 구현에 의존하지 않고 + * 재시도, 충돌 처리, 입력 보정을 결정할 수 있습니다.

+ */ +public enum AccountingTransitionStatus { + /** 요청한 회계 변경이 새로 적용되었습니다. */ + APPLIED, + + /** 같은 회계 명령과 값이 이미 적용되어 기존 결과가 재사용되었습니다. */ + REUSED, + + /** 기존에 적용된 종료 명령 또는 값이 새 요청과 충돌합니다. */ + CONFLICT, + + /** 대상 예약을 찾을 수 없습니다. */ + NOT_FOUND, + + /** 명령 금액의 통화가 예약 통화와 다릅니다. */ + CURRENCY_MISMATCH, + + /** 명령 인자가 계약을 만족하지 않습니다. */ + INVALID_ARGUMENT, + + /** 현재 예약 상태에서는 요청한 전이가 허용되지 않습니다. */ + NOT_ALLOWED; + + /** 새로운 회계 변경이 적용되었는지 반환합니다. */ + public boolean isApplied() { + return this == APPLIED; + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java new file mode 100644 index 0000000..1b4ca84 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ActualUsageCommand.java @@ -0,0 +1,38 @@ +package io.tokenpilot.budget; + +import io.tokenpilot.core.domain.TokenUsage; +import io.tokenpilot.core.domain.UsageSource; + +import java.util.Objects; + +/** + * provider가 보고한 actual usage를 하나의 예약에 정산하기 위한 명령입니다. + */ +public record ActualUsageCommand( + String requestId, + String attemptId, + ReservationId reservationId, + TokenUsage usage, + String responseModelId +) { + + public ActualUsageCommand { + requestId = requireText(requestId, "requestId"); + attemptId = requireText(attemptId, "attemptId"); + Objects.requireNonNull(reservationId, "reservationId must not be null"); + Objects.requireNonNull(usage, "usage must not be null"); + if (usage.source() == UsageSource.UNAVAILABLE) { + throw new IllegalArgumentException( + "usage must be available for actual reconciliation" + ); + } + responseModelId = requireText(responseModelId, "responseModelId"); + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java index d848ffd..443660e 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java @@ -1,9 +1,11 @@ package io.tokenpilot.budget; import io.tokenpilot.core.domain.Cost; +import io.tokenpilot.core.domain.PricingSnapshot; import java.time.Instant; import java.util.Objects; +import java.util.Optional; /** * 예산 bucket에 생성된 immutable 예약 snapshot입니다. @@ -13,10 +15,13 @@ public record BudgetReservation( BudgetKey key, Cost limit, Cost amount, + String requestId, IdempotencyKey idempotencyKey, String modelId, String pricingPolicyId, String catalogVersion, + Optional pricingSnapshot, + Optional tokenEstimate, ReservationState state, Instant createdAt ) { @@ -26,6 +31,7 @@ public record BudgetReservation( Objects.requireNonNull(key, "key must not be null"); Objects.requireNonNull(limit, "limit must not be null"); Objects.requireNonNull(amount, "amount must not be null"); + requestId = requireText(requestId, "requestId"); Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); Objects.requireNonNull(state, "state must not be null"); Objects.requireNonNull(createdAt, "createdAt must not be null"); @@ -38,6 +44,67 @@ public record BudgetReservation( modelId = optionalText(modelId, "modelId"); pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId"); catalogVersion = optionalText(catalogVersion, "catalogVersion"); + pricingSnapshot = Objects.requireNonNull( + pricingSnapshot, + "pricingSnapshot must not be null" + ); + tokenEstimate = Objects.requireNonNull( + tokenEstimate, + "tokenEstimate must not be null" + ); + if (pricingSnapshot.isPresent()) { + PricingSnapshot snapshot = pricingSnapshot.orElseThrow(); + requireSnapshotText(modelId, snapshot.modelId(), "modelId"); + requireSnapshotText( + pricingPolicyId, + snapshot.pricingPolicyId(), + "pricingPolicyId" + ); + requireSnapshotText( + catalogVersion, + snapshot.catalogVersion(), + "catalogVersion" + ); + if (!limit.currency().equals(snapshot.currency())) { + throw new IllegalArgumentException( + "pricing snapshot must use the budget currency" + ); + } + } + } + + /** + * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다. + * 신규 예약은 {@link #reserved(ReservationId, BudgetReservationRequest, Instant)}로 생성하세요. + */ + @Deprecated(since = "0.1.0", forRemoval = false) + public BudgetReservation( + ReservationId id, + BudgetKey key, + Cost limit, + Cost amount, + IdempotencyKey idempotencyKey, + String modelId, + String pricingPolicyId, + String catalogVersion, + ReservationState state, + Instant createdAt + ) { + this( + id, + key, + limit, + amount, + idempotencyKey.value(), + idempotencyKey, + modelId, + pricingPolicyId, + catalogVersion, + Optional.empty(), + Optional.empty(), + state, + createdAt + ); } public static BudgetReservation reserved( @@ -51,10 +118,13 @@ public static BudgetReservation reserved( request.key(), request.limit(), request.safeUpperBoundCost(), + request.requestId(), request.idempotencyKey(), request.modelId(), request.pricingPolicyId(), request.catalogVersion(), + request.pricingSnapshot(), + request.tokenEstimate(), ReservationState.RESERVED, createdAt ); @@ -64,10 +134,18 @@ public boolean matches(BudgetReservationRequest request) { return key.equals(request.key()) && limit.equals(request.limit()) && amount.equals(request.safeUpperBoundCost()) + && requestId.equals(request.requestId()) && idempotencyKey.equals(request.idempotencyKey()) && Objects.equals(modelId, request.modelId()) && Objects.equals(pricingPolicyId, request.pricingPolicyId()) - && Objects.equals(catalogVersion, request.catalogVersion()); + && Objects.equals(catalogVersion, request.catalogVersion()) + && pricingSnapshot.equals(request.pricingSnapshot()) + && tokenEstimate.equals(request.tokenEstimate()); + } + + /** 이 예약이 지정한 provider 요청에 속하는지 확인합니다. */ + public boolean belongsTo(String candidateRequestId) { + return requestId.equals(candidateRequestId); } private static String optionalText(String value, String name) { @@ -76,4 +154,23 @@ private static String optionalText(String value, String name) { } return value; } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + private static void requireSnapshotText( + String value, + String snapshotValue, + String name + ) { + if (!Objects.equals(value, snapshotValue)) { + throw new IllegalArgumentException( + name + " must match the pricing snapshot" + ); + } + } } diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java index 56e063b..bd7a433 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java @@ -1,8 +1,10 @@ package io.tokenpilot.budget; import io.tokenpilot.core.domain.Cost; +import io.tokenpilot.core.domain.PricingSnapshot; import java.util.Objects; +import java.util.Optional; /** * 호출 전 안전 상한 비용을 예산 bucket에 예약하기 위한 immutable 요청입니다. @@ -15,16 +17,20 @@ public record BudgetReservationRequest( BudgetKey key, Cost limit, Cost safeUpperBoundCost, + String requestId, IdempotencyKey idempotencyKey, String modelId, String pricingPolicyId, - String catalogVersion + String catalogVersion, + Optional pricingSnapshot, + Optional tokenEstimate ) { public BudgetReservationRequest { Objects.requireNonNull(key, "key must not be null"); Objects.requireNonNull(limit, "limit must not be null"); Objects.requireNonNull(safeUpperBoundCost, "safeUpperBoundCost must not be null"); + requestId = requireText(requestId, "requestId"); Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); if (limit.value().signum() <= 0) { throw new IllegalArgumentException("limit must be greater than zero"); @@ -32,8 +38,96 @@ public record BudgetReservationRequest( modelId = optionalText(modelId, "modelId"); pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId"); catalogVersion = optionalText(catalogVersion, "catalogVersion"); + pricingSnapshot = Objects.requireNonNull( + pricingSnapshot, + "pricingSnapshot must not be null" + ); + tokenEstimate = Objects.requireNonNull( + tokenEstimate, + "tokenEstimate must not be null" + ); + if (pricingSnapshot.isPresent()) { + PricingSnapshot snapshot = pricingSnapshot.orElseThrow(); + modelId = snapshotText(modelId, snapshot.modelId(), "modelId"); + pricingPolicyId = snapshotText( + pricingPolicyId, + snapshot.pricingPolicyId(), + "pricingPolicyId" + ); + catalogVersion = snapshotText( + catalogVersion, + snapshot.catalogVersion(), + "catalogVersion" + ); + if (!limit.currency().equals(snapshot.currency())) { + throw new IllegalArgumentException( + "pricing snapshot must use the budget currency" + ); + } + } } + /** + * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다. + * 신규 호출은 두 값을 명시하는 canonical 생성자를 사용하세요. + */ + @Deprecated(since = "0.1.0", forRemoval = false) + public BudgetReservationRequest( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + IdempotencyKey idempotencyKey, + String modelId, + String pricingPolicyId, + String catalogVersion + ) { + this( + key, + limit, + safeUpperBoundCost, + idempotencyKey.value(), + idempotencyKey, + modelId, + pricingPolicyId, + catalogVersion, + Optional.empty(), + Optional.empty() + ); + } + + /** + * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다. + * 신규 호출은 두 값을 명시하는 canonical 생성자를 사용하세요. + */ + @Deprecated(since = "0.1.0", forRemoval = false) + public BudgetReservationRequest( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + IdempotencyKey idempotencyKey, + String modelId, + String pricingPolicyId, + String catalogVersion, + Optional pricingSnapshot + ) { + this( + key, + limit, + safeUpperBoundCost, + idempotencyKey.value(), + idempotencyKey, + modelId, + pricingPolicyId, + catalogVersion, + pricingSnapshot, + Optional.empty() + ); + } + + /** + * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다. + */ + @Deprecated(since = "0.1.0", forRemoval = false) public BudgetReservationRequest( BudgetKey key, Cost limit, @@ -44,13 +138,20 @@ public BudgetReservationRequest( key, limit, safeUpperBoundCost, + idempotencyKey, new IdempotencyKey(idempotencyKey), null, null, - null + null, + Optional.empty(), + Optional.empty() ); } + /** + * @deprecated request ID와 idempotency key를 같은 값으로 사용하는 호환 생성자입니다. + */ + @Deprecated(since = "0.1.0", forRemoval = false) public BudgetReservationRequest( BudgetKey key, Cost limit, @@ -64,10 +165,61 @@ public BudgetReservationRequest( key, limit, safeUpperBoundCost, + idempotencyKey, new IdempotencyKey(idempotencyKey), modelId, pricingPolicyId, - catalogVersion + catalogVersion, + Optional.empty(), + Optional.empty() + ); + } + + public BudgetReservationRequest( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + String requestId, + IdempotencyKey idempotencyKey, + String modelId, + String pricingPolicyId, + String catalogVersion, + Optional pricingSnapshot + ) { + this( + key, + limit, + safeUpperBoundCost, + requestId, + idempotencyKey, + modelId, + pricingPolicyId, + catalogVersion, + pricingSnapshot, + Optional.empty() + ); + } + + public BudgetReservationRequest( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + String requestId, + IdempotencyKey idempotencyKey, + PricingSnapshot pricingSnapshot, + ReservationTokenEstimate tokenEstimate + ) { + this( + key, + limit, + safeUpperBoundCost, + requestId, + idempotencyKey, + pricingSnapshot.modelId(), + pricingSnapshot.pricingPolicyId(), + pricingSnapshot.catalogVersion(), + Optional.of(pricingSnapshot), + Optional.of(tokenEstimate) ); } @@ -77,4 +229,24 @@ private static String optionalText(String value, String name) { } return value; } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } + + private static String snapshotText( + String value, + String snapshotValue, + String name + ) { + if (value != null && !value.equals(snapshotValue)) { + throw new IllegalArgumentException( + name + " must match the pricing snapshot" + ); + } + return snapshotValue; + } } diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java index cfe8b14..cbc545e 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java @@ -11,7 +11,7 @@ public interface BudgetStateStore { * 기존 확정 비용 조회 API입니다. 예약 금액은 포함하지 않습니다. * *

새 provider admission 경계는 {@link #snapshot(BudgetKey, Cost)} 또는 - * {@link #checkAndReserve(BudgetKey, Cost, Cost, String)}를 사용해야 합니다.

+ * {@link #checkAndReserve(BudgetReservationRequest)}를 사용해야 합니다.

*/ Cost getAccumulatedCost(BudgetKey key, Cost limit); @@ -31,7 +31,9 @@ public interface BudgetStateStore { * @param safeUpperBoundCost 예약할 보수적 비용 상한 * @param idempotencyKey 중복 요청 식별자 * @return 생성·재사용·차단·충돌·통화 불일치 결과 + * @deprecated request ID와 idempotency key를 분리하는 overload 또는 요청 객체를 사용하세요. */ + @Deprecated(since = "0.1.0", forRemoval = false) default BudgetReservationResult checkAndReserve( BudgetKey key, Cost limit, @@ -48,7 +50,10 @@ default BudgetReservationResult checkAndReserve( /** * typed idempotency key를 사용하는 원자적 예약 overload입니다. + * + * @deprecated request ID와 idempotency key를 분리하는 overload 또는 요청 객체를 사용하세요. */ + @Deprecated(since = "0.1.0", forRemoval = false) default BudgetReservationResult checkAndReserve( BudgetKey key, Cost limit, @@ -66,6 +71,29 @@ default BudgetReservationResult checkAndReserve( )); } + /** + * 요청 상관관계와 중복 방지 식별자를 분리하는 원자적 예약 overload입니다. + */ + default BudgetReservationResult checkAndReserve( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + String requestId, + IdempotencyKey idempotencyKey + ) { + return checkAndReserve(new BudgetReservationRequest( + key, + limit, + safeUpperBoundCost, + requestId, + idempotencyKey, + null, + null, + null, + java.util.Optional.empty() + )); + } + /** * 모델·가격 snapshot metadata를 포함한 원자적 예약 요청입니다. */ diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java new file mode 100644 index 0000000..962bb8e --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccounting.java @@ -0,0 +1,131 @@ +package io.tokenpilot.budget; + +/** + * 예약의 회계 상태와 금액을 변경하는 단일 진입점입니다. + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
예약 회계 명령의 허용 전이와 금액 이동
명령허용 상태결과 상태금액 이동
{@link #markInFlight(ReservationId)}{@link ReservationState#RESERVED}{@link ReservationState#IN_FLIGHT}없음
{@code release(CANCELLED_BEFORE_DISPATCH)}{@link ReservationState#RESERVED}{@link ReservationState#RELEASED}{@code activeReservedCost -= estimate}
{@code release(PROVIDER_CONFIRMED_UNBILLED)}{@link ReservationState#IN_FLIGHT}{@link ReservationState#RELEASED}{@code activeReservedCost -= estimate}
{@link #commit(ActualUsageCommand)}{@link ReservationState#IN_FLIGHT}{@link ReservationState#COMMITTED}{@code activeReservedCost -= estimate; committedCost += actual}
{@link #markReconciliationRequired(ReservationId, ReservationAccountingReason)}{@link ReservationState#IN_FLIGHT}{@link ReservationState#RECONCILIATION_REQUIRED}{@code activeReservedCost -= estimate; pendingReconciliationLiability += estimate}
{@link #reconcileLateActual(ActualUsageCommand)}{@link ReservationState#RECONCILIATION_REQUIRED}{@link ReservationState#COMMITTED}{@code pendingReconciliationLiability -= estimate; committedCost += actual}
{@link #writeOff(ReservationId, ReservationAccountingReason)}{@link ReservationState#RECONCILIATION_REQUIRED}{@link ReservationState#WRITTEN_OFF}{@code pendingReconciliationLiability -= estimate}
+ * + *

표의 전이가 새로 적용되면 {@link AccountingTransitionStatus#APPLIED}입니다. + * 동일한 종료 명령과 값 또는 정산 대기 명령의 재호출은 상태와 금액을 유지하고 + * {@link AccountingTransitionStatus#REUSED}, 다른 actual 또는 상충하는 종료 명령은 + * {@link AccountingTransitionStatus#CONFLICT}입니다. 아직 종료 명령이 적용되지 않았지만 + * 현재 상태가 표의 허용 상태가 아니면 {@link AccountingTransitionStatus#NOT_ALLOWED}입니다.

+ * + *

존재하지 않는 예약과 명령에 허용되지 않은 reason은 상태를 변경하기 전에 + * {@link IllegalArgumentException}으로 거부합니다. 모든 상태와 금액 변경은 같은 budget + * bucket의 임계 구역 안에서 함께 적용됩니다.

+ */ +public interface ReservationAccounting { + + /** 예약을 사용한 provider 호출 시작을 기록합니다. */ + ReservationTransition markInFlight(ReservationId reservationId); + + /** provider 호출 전에 사용하지 않은 예약을 해제합니다. */ + default ReservationTransition releaseBeforeDispatch(ReservationId reservationId) { + return release( + reservationId, + ReservationAccountingReason.CANCELLED_BEFORE_DISPATCH + ); + } + + /** provider가 미과금을 확인한 진행 중 예약을 해제합니다. */ + default ReservationTransition releaseConfirmedUnbilled(ReservationId reservationId) { + return release( + reservationId, + ReservationAccountingReason.PROVIDER_CONFIRMED_UNBILLED + ); + } + + ReservationTransition release( + ReservationId reservationId, + ReservationAccountingReason reason + ); + + /** provider actual usage를 예약 시점 가격으로 계산하여 확정합니다. */ + ReservationReconciliation commit(ActualUsageCommand command); + + /** actual을 확보하지 못한 예약을 정산 대기로 전환합니다. */ + default ReservationTransition markReconciliationRequired( + ReservationId reservationId + ) { + return markReconciliationRequired( + reservationId, + ReservationAccountingReason.ACTUAL_USAGE_UNAVAILABLE + ); + } + + ReservationTransition markReconciliationRequired( + ReservationId reservationId, + ReservationAccountingReason reason + ); + + /** 늦게 도착한 provider actual usage를 예약 시점 가격으로 계산하여 확정합니다. */ + ReservationReconciliation reconcileLateActual(ActualUsageCommand command); + + /** 후속 정산할 수 없는 pending 예약을 명시적으로 상각합니다. */ + default ReservationTransition writeOff(ReservationId reservationId) { + return writeOff( + reservationId, + ReservationAccountingReason.MANUAL_WRITE_OFF + ); + } + + ReservationTransition writeOff( + ReservationId reservationId, + ReservationAccountingReason reason + ); +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingEvent.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingEvent.java new file mode 100644 index 0000000..00438d9 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingEvent.java @@ -0,0 +1,16 @@ +package io.tokenpilot.budget; + +import java.util.Objects; + +/** 새롭게 적용된 예약 정산을 전달하는 회계 이벤트입니다. */ +public record ReservationAccountingEvent( + ReservationReconciliation reconciliation +) { + + public ReservationAccountingEvent { + Objects.requireNonNull( + reconciliation, + "reconciliation must not be null" + ); + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingListener.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingListener.java new file mode 100644 index 0000000..57d206b --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingListener.java @@ -0,0 +1,8 @@ +package io.tokenpilot.budget; + +/** 예약 정산 이벤트를 수신하는 framework-independent 계약입니다. */ +@FunctionalInterface +public interface ReservationAccountingListener { + + void onCommitted(ReservationAccountingEvent event); +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingReason.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingReason.java new file mode 100644 index 0000000..af50e02 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationAccountingReason.java @@ -0,0 +1,13 @@ +package io.tokenpilot.budget; + +/** 회계 명령과 정산 결과에 사용하는 제한된 사유입니다. */ +public enum ReservationAccountingReason { + ACTUAL_USAGE_REPORTED, + LATE_ACTUAL_USAGE_REPORTED, + ACTUAL_USAGE_UNAVAILABLE, + CALLBACK_TIMED_OUT, + CANCELLED_BEFORE_DISPATCH, + PROVIDER_CONFIRMED_UNBILLED, + MANUAL_WRITE_OFF, + ACTUAL_USAGE_UNRECOVERABLE +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationActualTokens.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationActualTokens.java new file mode 100644 index 0000000..d5e377b --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationActualTokens.java @@ -0,0 +1,45 @@ +package io.tokenpilot.budget; + +import io.tokenpilot.core.domain.TokenUsage; +import io.tokenpilot.core.domain.TokenUsageDetails; +import io.tokenpilot.core.domain.UsageSource; + +import java.util.Map; +import java.util.Objects; + +/** Provider actual usage에서 무제한 metadata를 제외한 회계용 token 결과입니다. */ +public record ReservationActualTokens( + long inputTokens, + long outputTokens, + TokenUsageDetails details, + UsageSource source +) { + + public ReservationActualTokens { + if (inputTokens < 0 || outputTokens < 0) { + throw new IllegalArgumentException("actual tokens must be non-negative"); + } + Objects.requireNonNull(details, "details must not be null"); + Objects.requireNonNull(source, "source must not be null"); + if (source == UsageSource.UNAVAILABLE) { + throw new IllegalArgumentException( + "actual token source must be available" + ); + } + new TokenUsage(inputTokens, outputTokens, details, source, Map.of()); + } + + public static ReservationActualTokens from(TokenUsage usage) { + Objects.requireNonNull(usage, "usage must not be null"); + return new ReservationActualTokens( + usage.inputTokens(), + usage.outputTokens(), + usage.details(), + usage.source() + ); + } + + public long totalTokens() { + return Math.addExact(inputTokens, outputTokens); + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java new file mode 100644 index 0000000..be34bea --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationReconciliation.java @@ -0,0 +1,107 @@ +package io.tokenpilot.budget; + +import io.tokenpilot.core.domain.Cost; +import io.tokenpilot.core.domain.PricingSnapshot; + +import java.math.BigDecimal; +import java.util.Currency; +import java.util.Objects; + +/** + * 한 예약의 estimate와 actual 비용을 연결한 회계 정산 결과입니다. + */ +public record ReservationReconciliation( + String requestId, + String attemptId, + ReservationId reservationId, + BudgetKey budgetKey, + String responseModelId, + PricingSnapshot pricingSnapshot, + ReservationTokenEstimate tokenEstimate, + ReservationActualTokens actualTokens, + Cost estimate, + Cost actual, + boolean overLimit, + ReservationTransition transition, + ReservationAccountingReason reason +) { + + public ReservationReconciliation { + requestId = requireText(requestId, "requestId"); + attemptId = requireText(attemptId, "attemptId"); + Objects.requireNonNull(reservationId, "reservationId must not be null"); + Objects.requireNonNull(budgetKey, "budgetKey must not be null"); + responseModelId = requireText(responseModelId, "responseModelId"); + Objects.requireNonNull(pricingSnapshot, "pricingSnapshot must not be null"); + Objects.requireNonNull(tokenEstimate, "tokenEstimate must not be null"); + Objects.requireNonNull(actualTokens, "actualTokens must not be null"); + Objects.requireNonNull(estimate, "estimate must not be null"); + Objects.requireNonNull(actual, "actual must not be null"); + Objects.requireNonNull(transition, "transition must not be null"); + Objects.requireNonNull(reason, "reason must not be null"); + if (!estimate.currency().equals(actual.currency())) { + throw new IllegalArgumentException( + "estimate and actual must use the same currency" + ); + } + if (!estimate.currency().equals(pricingSnapshot.currency())) { + throw new IllegalArgumentException( + "reconciliation costs must use the pricing snapshot currency" + ); + } + } + + /** 예약 시점 pricing snapshot의 request model입니다. */ + public String requestModelId() { + return pricingSnapshot.modelId(); + } + + /** 예약 시점 pricing policy 식별자입니다. */ + public String pricingPolicyId() { + return pricingSnapshot.pricingPolicyId(); + } + + /** 예약 시점 model catalog version입니다. */ + public String catalogVersion() { + return pricingSnapshot.catalogVersion(); + } + + /** actual에서 estimate를 뺀 signed 비용 차이입니다. */ + public BigDecimal delta() { + return actual.value().subtract(estimate.value()); + } + + public long inputTokenDelta() { + return Math.subtractExact( + actualTokens.inputTokens(), + tokenEstimate.inputEstimatedTokens() + ); + } + + public long outputTokenDelta() { + return Math.subtractExact( + actualTokens.outputTokens(), + tokenEstimate.reservedOutputTokens() + ); + } + + public long totalTokenDelta() { + long estimatedTotal = Math.addExact( + tokenEstimate.inputEstimatedTokens(), + tokenEstimate.reservedOutputTokens() + ); + return Math.subtractExact(actualTokens.totalTokens(), estimatedTotal); + } + + /** estimate와 actual이 사용하는 통화입니다. */ + public Currency currency() { + return actual.currency(); + } + + private static String requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value; + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java index 3a1a2cf..4a1cd07 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java @@ -2,9 +2,32 @@ /** * 예산 예약의 현재 회계 상태입니다. - * - *

commit/release/reconciliation 상태 전이는 #37에서 확장합니다.

*/ public enum ReservationState { - RESERVED + /** 안전 상한액이 예약되었지만 공급자 호출은 시작되지 않은 상태입니다. */ + RESERVED, + + /** 예약을 사용해 공급자 호출이 진행 중인 상태입니다. */ + IN_FLIGHT, + + /** 실제 사용량을 알 수 없어 후속 정산이 필요한 상태입니다. */ + RECONCILIATION_REQUIRED, + + /** 실제 사용 금액이 확정된 종료 상태입니다. */ + COMMITTED, + + /** 사용되지 않은 예약 금액이 해제된 종료 상태입니다. */ + RELEASED, + + /** 실제 사용량을 끝내 확정할 수 없어 정책에 따라 상각된 종료 상태입니다. */ + WRITTEN_OFF; + + /** + * 더 이상 정상적인 회계 전이를 허용하지 않는 종료 상태인지 반환합니다. + * + *

{@link #RECONCILIATION_REQUIRED}는 후속 정산을 기다리는 상태이므로 종료 상태가 아닙니다.

+ */ + public boolean isClosed() { + return this == COMMITTED || this == RELEASED || this == WRITTEN_OFF; + } } diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java new file mode 100644 index 0000000..812b575 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStateMachine.java @@ -0,0 +1,94 @@ +package io.tokenpilot.budget; + +import java.util.Objects; + +/** + * 예약의 회계 상태 전이 가능 여부를 판단합니다. + */ +public final class ReservationStateMachine { + + private ReservationStateMachine() { + } + + /** 예약된 요청의 공급자 호출을 시작합니다. */ + public static ReservationTransition onDispatch(ReservationState currentState) { + return transitionFrom( + currentState, + ReservationState.RESERVED, + ReservationState.IN_FLIGHT + ); + } + + /** 공급자 호출 전에 예약을 해제합니다. */ + public static ReservationTransition release(ReservationState currentState) { + return transitionFrom( + currentState, + ReservationState.RESERVED, + ReservationState.RELEASED + ); + } + + /** 공급자가 미과금을 확인한 진행 중 예약을 해제합니다. */ + public static ReservationTransition releaseConfirmedUnbilled( + ReservationState currentState + ) { + return transitionFrom( + currentState, + ReservationState.IN_FLIGHT, + ReservationState.RELEASED + ); + } + + /** 전달받은 actual을 확정하기 위한 상태 전이를 판단합니다. */ + public static ReservationTransition commit(ReservationState currentState) { + return transitionFrom( + currentState, + ReservationState.IN_FLIGHT, + ReservationState.COMMITTED + ); + } + + /** actual을 전달받지 못한 예약을 정산 대기로 전환할 수 있는지 판단합니다. */ + public static ReservationTransition markReconciliationRequired(ReservationState currentState) { + return transitionFrom( + currentState, + ReservationState.IN_FLIGHT, + ReservationState.RECONCILIATION_REQUIRED + ); + } + + /** 정산 대기 중 전달받은 late actual을 확정할 수 있는지 판단합니다. */ + public static ReservationTransition reconcileLateActual(ReservationState currentState) { + return transitionFrom( + currentState, + ReservationState.RECONCILIATION_REQUIRED, + ReservationState.COMMITTED + ); + } + + /** 정산 대기 중인 예약을 명시적으로 상각합니다. */ + public static ReservationTransition writeOff(ReservationState currentState) { + return transitionFrom( + currentState, + ReservationState.RECONCILIATION_REQUIRED, + ReservationState.WRITTEN_OFF + ); + } + + private static ReservationTransition transitionFrom( + ReservationState currentState, + ReservationState requiredState, + ReservationState resultingState + ) { + Objects.requireNonNull(currentState, "currentState must not be null"); + + if (currentState == requiredState) { + return ReservationTransition.applied(currentState, resultingState); + } + + return ReservationTransition.unchanged( + currentState, + AccountingTransitionStatus.NOT_ALLOWED + ); + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTokenEstimate.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTokenEstimate.java new file mode 100644 index 0000000..3e59e05 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTokenEstimate.java @@ -0,0 +1,22 @@ +package io.tokenpilot.budget; + +/** 예약 비용의 근거가 된 호출 전 token estimate입니다. */ +public record ReservationTokenEstimate( + long inputEstimatedTokens, + long inputSafeUpperBoundTokens, + long reservedOutputTokens +) { + + public ReservationTokenEstimate { + if (inputEstimatedTokens < 0 + || inputSafeUpperBoundTokens < 0 + || reservedOutputTokens < 0) { + throw new IllegalArgumentException("estimated tokens must be non-negative"); + } + if (inputSafeUpperBoundTokens < inputEstimatedTokens) { + throw new IllegalArgumentException( + "inputSafeUpperBoundTokens must be greater than or equal to inputEstimatedTokens" + ); + } + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTransition.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTransition.java new file mode 100644 index 0000000..ed66bc0 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationTransition.java @@ -0,0 +1,44 @@ +package io.tokenpilot.budget; + +import java.util.Objects; + +/** + * 예약의 상태 전이 판단입니다. + */ +public record ReservationTransition( + ReservationState previousState, + ReservationState resultingState, + AccountingTransitionStatus status +) { + + public ReservationTransition { + Objects.requireNonNull(previousState, "previousState must not be null"); + Objects.requireNonNull(resultingState, "resultingState must not be null"); + Objects.requireNonNull(status, "status must not be null"); + + boolean stateChanged = previousState != resultingState; + if (status.isApplied() != stateChanged) { + throw new IllegalArgumentException( + "APPLIED must change reservation state and all other statuses must preserve it" + ); + } + } + + public static ReservationTransition applied( + ReservationState previousState, + ReservationState resultingState + ) { + return new ReservationTransition( + previousState, + resultingState, + AccountingTransitionStatus.APPLIED + ); + } + + public static ReservationTransition unchanged( + ReservationState state, + AccountingTransitionStatus status + ) { + return new ReservationTransition(state, state, status); + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ActualUsageFingerprint.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ActualUsageFingerprint.java new file mode 100644 index 0000000..2f08d41 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ActualUsageFingerprint.java @@ -0,0 +1,28 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.budget.ActualUsageCommand; +import io.tokenpilot.budget.ReservationActualTokens; +import io.tokenpilot.budget.ReservationId; + +/** 중복 actual callback을 민감하거나 무제한인 metadata 없이 식별합니다. */ +record ActualUsageFingerprint( + String requestId, + String attemptId, + ReservationId reservationId, + ReservationActualTokens actualTokens, + String responseModelId +) { + + static ActualUsageFingerprint from( + ActualUsageCommand command, + ReservationActualTokens actualTokens + ) { + return new ActualUsageFingerprint( + command.requestId(), + command.attemptId(), + command.reservationId(), + actualTokens, + command.responseModelId() + ); + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java new file mode 100644 index 0000000..5e4c937 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedCommit.java @@ -0,0 +1,94 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.budget.AccountingTransitionStatus; +import io.tokenpilot.budget.ReservationState; +import io.tokenpilot.budget.ReservationTransition; +import io.tokenpilot.core.domain.Cost; + +import java.util.Objects; +import java.util.Optional; + +import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT; +import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED; + +/** + * 이미 적용된 commit의 종류, actual과 재호출 판단을 보관합니다. + */ +record AppliedCommit( + CommitType type, + Cost actualCost, + boolean overLimit, + Optional fingerprint +) { + + AppliedCommit { + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(actualCost, "actualCost must not be null"); + fingerprint = Objects.requireNonNull( + fingerprint, + "fingerprint must not be null" + ); + } + + static AppliedCommit costOnly(CommitType type, Cost actualCost) { + return new AppliedCommit( + type, + actualCost, + false, + Optional.empty() + ); + } + + static AppliedCommit fromCallback( + CommitType type, + Cost actualCost, + boolean overLimit, + ActualUsageFingerprint fingerprint + ) { + return new AppliedCommit( + type, + actualCost, + overLimit, + Optional.of(fingerprint) + ); + } + + boolean matches( + CommitType requestedType, + ActualUsageFingerprint requestedFingerprint + ) { + return type == requestedType + && fingerprint.filter(requestedFingerprint::equals).isPresent(); + } + + ReservationTransition evaluate( + CommitType requestedType, + ReservationState state, + Cost requestedActualCost, + Optional requestedFingerprint + ) { + Objects.requireNonNull(requestedType, "requestedType must not be null"); + Objects.requireNonNull(state, "state must not be null"); + Objects.requireNonNull(requestedActualCost, "requestedActualCost must not be null"); + Objects.requireNonNull( + requestedFingerprint, + "requestedFingerprint must not be null" + ); + + AccountingTransitionStatus status = type == requestedType + && actualCost.equals(requestedActualCost) + && matchesFingerprintWhenProvided(requestedFingerprint) + ? REUSED + : CONFLICT; + return ReservationTransition.unchanged(state, status); + } + + private boolean matchesFingerprintWhenProvided( + Optional requestedFingerprint + ) { + if (fingerprint.isEmpty() || requestedFingerprint.isEmpty()) { + return true; + } + return fingerprint.equals(requestedFingerprint); + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java new file mode 100644 index 0000000..fdc6c8d --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/AppliedRelease.java @@ -0,0 +1,39 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.budget.AccountingTransitionStatus; +import io.tokenpilot.budget.ReservationState; +import io.tokenpilot.budget.ReservationTransition; + +import java.util.Objects; + +import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT; +import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED; + +/** + * 이미 적용된 release의 종류와 재호출 판단을 보관합니다. + */ +record AppliedRelease(ReleaseType type) { + + AppliedRelease { + Objects.requireNonNull(type, "type must not be null"); + } + + static AppliedRelease beforeDispatch() { + return new AppliedRelease(ReleaseType.BEFORE_DISPATCH); + } + + static AppliedRelease confirmedUnbilled() { + return new AppliedRelease(ReleaseType.CONFIRMED_UNBILLED); + } + + ReservationTransition evaluate( + ReleaseType requestedType, + ReservationState state + ) { + Objects.requireNonNull(requestedType, "requestedType must not be null"); + Objects.requireNonNull(state, "state must not be null"); + + AccountingTransitionStatus status = type == requestedType ? REUSED : CONFLICT; + return ReservationTransition.unchanged(state, status); + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/CommitType.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/CommitType.java new file mode 100644 index 0000000..e1f780a --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/CommitType.java @@ -0,0 +1,7 @@ +package io.tokenpilot.budget.internal; + +/** Actual 비용이 확정된 경로입니다. */ +enum CommitType { + DIRECT, + LATE_ACTUAL +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java index 97bb3de..dfb3d71 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java @@ -1,5 +1,7 @@ package io.tokenpilot.budget.internal; +import io.tokenpilot.budget.ActualUsageCommand; +import io.tokenpilot.budget.AccountingTransitionStatus; import io.tokenpilot.budget.BudgetKey; import io.tokenpilot.budget.BudgetReservation; import io.tokenpilot.budget.BudgetReservationRequest; @@ -7,13 +9,29 @@ import io.tokenpilot.budget.BudgetSnapshot; import io.tokenpilot.budget.BudgetStateStore; import io.tokenpilot.budget.IdempotencyKey; +import io.tokenpilot.budget.ReservationAccounting; +import io.tokenpilot.budget.ReservationAccountingEvent; +import io.tokenpilot.budget.ReservationAccountingListener; +import io.tokenpilot.budget.ReservationAccountingReason; +import io.tokenpilot.budget.ReservationActualTokens; import io.tokenpilot.budget.ReservationId; +import io.tokenpilot.budget.ReservationReconciliation; +import io.tokenpilot.budget.ReservationState; +import io.tokenpilot.budget.ReservationStateMachine; +import io.tokenpilot.budget.ReservationTransition; +import io.tokenpilot.budget.ReservationTokenEstimate; +import io.tokenpilot.core.CostCalculator; import io.tokenpilot.core.domain.Cost; +import io.tokenpilot.core.domain.PricingSnapshot; +import io.tokenpilot.core.internal.LedgerComponents; import java.time.Clock; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicReference; @@ -25,27 +43,66 @@ *

bucket별 monitor가 조회·통화 검증·한도 검증·예약 갱신을 함께 보호하고, * 별도의 idempotency index가 같은 요청의 중복 예약을 차단합니다.

*/ -public class InMemoryBudgetStateStore implements BudgetStateStore { +public class InMemoryBudgetStateStore implements BudgetStateStore, ReservationAccounting { private final ConcurrentMap store = new ConcurrentHashMap<>(); - private final ConcurrentMap idempotencyIndex = + private final ConcurrentMap idempotencyIndex = + new ConcurrentHashMap<>(); + private final ConcurrentMap reservationIndex = new ConcurrentHashMap<>(); private final Clock clock; private final Supplier reservationIdGenerator; + private final CostCalculator costCalculator; + private final List accountingListeners; public InMemoryBudgetStateStore() { - this(Clock.systemUTC(), ReservationId::random); + this( + Clock.systemUTC(), + ReservationId::random, + LedgerComponents.defaultCostCalculator() + ); } public InMemoryBudgetStateStore( Clock clock, Supplier reservationIdGenerator + ) { + this( + clock, + reservationIdGenerator, + LedgerComponents.defaultCostCalculator() + ); + } + + public InMemoryBudgetStateStore( + Clock clock, + Supplier reservationIdGenerator, + CostCalculator costCalculator + ) { + this(clock, reservationIdGenerator, costCalculator, List.of()); + } + + public InMemoryBudgetStateStore( + Clock clock, + Supplier reservationIdGenerator, + CostCalculator costCalculator, + List accountingListeners ) { this.clock = Objects.requireNonNull(clock, "clock must not be null"); this.reservationIdGenerator = Objects.requireNonNull( reservationIdGenerator, "reservationIdGenerator must not be null" ); + this.costCalculator = Objects.requireNonNull( + costCalculator, + "costCalculator must not be null" + ); + this.accountingListeners = List.copyOf( + Objects.requireNonNull( + accountingListeners, + "accountingListeners must not be null" + ) + ); } @Override @@ -79,88 +136,251 @@ public BudgetReservationResult checkAndReserve(BudgetReservationRequest request) Objects.requireNonNull(request, "request must not be null"); AtomicReference result = new AtomicReference<>(); - idempotencyIndex.compute(request.idempotencyKey(), (ignored, existing) -> { - if (existing != null) { - BudgetSnapshot existingSnapshot = snapshot( - existing.key(), - existing.limit() - ); - if (existing.matches(request)) { - result.set(BudgetReservationResult.reused(existing, existingSnapshot)); - } else { - result.set(BudgetReservationResult.conflict( - existing, - existingSnapshot, - "동일 idempotency key에 다른 예약 요청이 사용되었습니다" - )); - } - return existing; + idempotencyIndex.compute( + request.idempotencyKey(), + (ignored, existingReservationId) -> reserveOrReturnExisting( + request, + existingReservationId, + result + ) + ); + + return Objects.requireNonNull(result.get(), "reservation result must be set"); + } + + @Override + public ReservationTransition markInFlight(ReservationId reservationId) { + return updateState(reservationId, ReservationStateMachine::onDispatch); + } + + @Override + public ReservationTransition release( + ReservationId reservationId, + ReservationAccountingReason reason + ) { + Objects.requireNonNull(reason, "reason must not be null"); + return switch (reason) { + case CANCELLED_BEFORE_DISPATCH -> releaseBeforeDispatch( + reservationId, + reason + ); + case PROVIDER_CONFIRMED_UNBILLED -> releaseConfirmedUnbilled( + reservationId, + reason + ); + default -> throw new IllegalArgumentException( + "reason is not valid for release" + ); + }; + } + + private ReservationTransition releaseBeforeDispatch( + ReservationId reservationId, + ReservationAccountingReason reason + ) { + Bucket bucket = bucketFor(reservationId); + synchronized (bucket) { + ReservationAccountingState accountingState = accountingState( + bucket, + reservationId + ); + BudgetReservation reservation = accountingState.reservation(); + ReservationTransition transition = + accountingState.evaluateReleaseBeforeDispatch(); + if (!transition.status().isApplied()) { + return transition; } - if (!request.limit().currency().equals(request.safeUpperBoundCost().currency())) { - result.set(BudgetReservationResult.currencyMismatch( - BudgetSnapshot.empty(request.key(), request.limit()) - )); - return null; + BudgetReservation updated = withState( + reservation, + transition.resultingState() + ); + releaseActiveReservation( + bucket, + accountingState, + accountingState.releasedBeforeDispatch(updated) + ); + return transition; + } + } + + private ReservationTransition releaseConfirmedUnbilled( + ReservationId reservationId, + ReservationAccountingReason reason + ) { + Bucket bucket = bucketFor(reservationId); + synchronized (bucket) { + ReservationAccountingState accountingState = accountingState( + bucket, + reservationId + ); + BudgetReservation reservation = accountingState.reservation(); + ReservationTransition transition = + accountingState.evaluateConfirmedUnbilledRelease(); + if (!transition.status().isApplied()) { + return transition; } - Bucket bucket = store.computeIfAbsent( - request.key(), - ignoredKey -> new Bucket(request.limit()) - ); - synchronized (bucket) { - if (!bucket.limit.currency().equals(request.limit().currency())) { - result.set(BudgetReservationResult.currencyMismatch( - bucket.snapshot(request.key()) - )); - return null; - } - if (!bucket.limit.equals(request.limit())) { - result.set(BudgetReservationResult.conflict( - null, - bucket.snapshot(request.key()), - "기존 budget bucket의 limit snapshot이 변경되었습니다" - )); - return null; - } - - Cost projectedUsage = bucket.effectiveUsage().add(request.safeUpperBoundCost()); - if (projectedUsage.compareTo(request.limit()) >= 0) { - result.set(BudgetReservationResult.blocked( - bucket.snapshot(request.key()), - "예약 후 사용량이 예산 한도에 도달하거나 초과합니다" - )); - return null; - } - - ReservationId reservationId = Objects.requireNonNull( - reservationIdGenerator.get(), - "reservationIdGenerator returned null" - ); - BudgetReservation reservation = BudgetReservation.reserved( - reservationId, - request, - clock.instant() - ); - if (bucket.reservationsById.containsKey(reservationId)) { - throw new IllegalStateException( - "reservationIdGenerator returned a duplicate reservation id" - ); - } - - bucket.activeReservedCost = bucket.activeReservedCost.add( - request.safeUpperBoundCost() + BudgetReservation updated = withState( + reservation, + transition.resultingState() + ); + releaseActiveReservation( + bucket, + accountingState, + accountingState.confirmedUnbilledReleased(updated) + ); + return transition; + } + } + + ReservationTransition commitCost(ReservationId reservationId, Cost actualCost) { + return applyCost( + reservationId, + actualCost, + CommitType.DIRECT, + Optional.empty() + ).transition(); + } + + @Override + public ReservationReconciliation commit(ActualUsageCommand command) { + return reconcileUsage( + command, + CommitType.DIRECT, + ReservationAccountingReason.ACTUAL_USAGE_REPORTED + ); + } + + @Override + public ReservationTransition markReconciliationRequired( + ReservationId reservationId, + ReservationAccountingReason reason + ) { + requireReconciliationRequiredReason(reason); + Bucket bucket = bucketFor(reservationId); + synchronized (bucket) { + ReservationAccountingState accountingState = accountingState( + bucket, + reservationId + ); + ReservationTransition transition = + accountingState.evaluateReconciliationRequired(); + if (!transition.status().isApplied()) { + return transition; + } + + moveActiveReservationToPending( + bucket, + accountingState, + transition.resultingState() + ); + return transition; + } + } + + ReservationTransition reconcileLateActualCost( + ReservationId reservationId, + Cost actualCost + ) { + return applyCost( + reservationId, + actualCost, + CommitType.LATE_ACTUAL, + Optional.empty() + ).transition(); + } + + private AccountingTransitionOutcome applyCost( + ReservationId reservationId, + Cost actualCost, + CommitType type, + Optional fingerprint + ) { + Objects.requireNonNull(actualCost, "actualCost must not be null"); + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + Bucket bucket = bucketFor(reservationId); + synchronized (bucket) { + ReservationAccountingState accountingState = accountingState( + bucket, + reservationId + ); + return applyCostInBucket( + bucket, + accountingState, + actualCost, + type, + fingerprint + ); + } + } + + private AccountingTransitionOutcome applyCostInBucket( + Bucket bucket, + ReservationAccountingState accountingState, + Cost actualCost, + CommitType type, + Optional fingerprint + ) { + ReservationTransition transition = type == CommitType.DIRECT + ? accountingState.evaluateCommit(actualCost, fingerprint) + : accountingState.evaluateLateActual(actualCost, fingerprint); + if (!transition.status().isApplied()) { + return outcome(bucket, transition); + } + + boolean overLimit = type == CommitType.DIRECT + ? commitActiveReservation( + bucket, + accountingState, + actualCost, + transition.resultingState(), + fingerprint + ) + : commitPendingReservation( + bucket, + accountingState, + actualCost, + transition.resultingState(), + fingerprint ); - bucket.reservationsById.put(reservationId, reservation); - result.set(BudgetReservationResult.created( - reservation, - bucket.snapshot(request.key()) - )); - return reservation; + return new AccountingTransitionOutcome(transition, overLimit); + } + + @Override + public ReservationReconciliation reconcileLateActual(ActualUsageCommand command) { + return reconcileUsage( + command, + CommitType.LATE_ACTUAL, + ReservationAccountingReason.LATE_ACTUAL_USAGE_REPORTED + ); + } + + @Override + public ReservationTransition writeOff( + ReservationId reservationId, + ReservationAccountingReason reason + ) { + requireWriteOffReason(reason); + Bucket bucket = bucketFor(reservationId); + synchronized (bucket) { + ReservationAccountingState accountingState = accountingState( + bucket, + reservationId + ); + ReservationTransition transition = accountingState.evaluateWriteOff(); + if (!transition.status().isApplied()) { + return transition; } - }); - return Objects.requireNonNull(result.get(), "reservation result must be set"); + writeOffPendingReservation( + bucket, + accountingState, + transition.resultingState() + ); + return transition; + } } @Override @@ -190,12 +410,556 @@ private void validateCurrency(Cost limit, Cost amount) { } } + private ReservationReconciliation reconcileUsage( + ActualUsageCommand command, + CommitType type, + ReservationAccountingReason reason + ) { + Objects.requireNonNull(command, "command must not be null"); + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(reason, "reason must not be null"); + Bucket bucket = bucketFor(command.reservationId()); + ReservationReconciliation reconciliation; + synchronized (bucket) { + reconciliation = reconcileUsageInBucket( + bucket, + command, + type, + reason + ); + } + publishAccountingEvent(reconciliation); + return reconciliation; + } + + private void publishAccountingEvent( + ReservationReconciliation reconciliation + ) { + if (!reconciliation.transition().status().isApplied() + || accountingListeners.isEmpty()) { + return; + } + ReservationAccountingEvent event = new ReservationAccountingEvent( + reconciliation + ); + for (ReservationAccountingListener listener : accountingListeners) { + notifyBestEffort(listener, event); + } + } + + private static void notifyBestEffort( + ReservationAccountingListener listener, + ReservationAccountingEvent event + ) { + try { + listener.onCommitted(event); + } catch (RuntimeException ignored) { + // Listener 실패는 이미 적용된 회계 상태를 되돌리지 않습니다. + } + } + + private ReservationReconciliation reconcileUsageInBucket( + Bucket bucket, + ActualUsageCommand command, + CommitType type, + ReservationAccountingReason reason + ) { + ReservationAccountingState accountingState = accountingState( + bucket, + command.reservationId() + ); + BudgetReservation reservation = accountingState.reservation(); + if (!reservation.belongsTo(command.requestId())) { + throw new IllegalArgumentException( + "requestId must match the reservation request" + ); + } + + PricingSnapshot snapshot = reservation.pricingSnapshot().orElseThrow( + () -> new IllegalStateException( + "reservation does not contain a pricing snapshot" + ) + ); + ReservationTokenEstimate tokenEstimate = reservation.tokenEstimate().orElseThrow( + () -> new IllegalStateException( + "reservation does not contain a token estimate" + ) + ); + ReservationActualTokens actualTokens = ReservationActualTokens.from( + command.usage() + ); + ActualUsageFingerprint fingerprint = ActualUsageFingerprint.from( + command, + actualTokens + ); + Optional reusedCommit = + accountingState.reusedCommit(type, fingerprint); + if (reusedCommit.isPresent()) { + AppliedCommit appliedCommit = reusedCommit.orElseThrow(); + AccountingTransitionOutcome reused = new AccountingTransitionOutcome( + ReservationTransition.unchanged( + reservation.state(), + AccountingTransitionStatus.REUSED + ), + appliedCommit.overLimit() + ); + return reconciliation( + command, + reservation, + snapshot, + tokenEstimate, + actualTokens, + appliedCommit.actualCost(), + reused, + reason + ); + } + + Cost actualCost = calculateActualCost(command, snapshot); + AccountingTransitionOutcome outcome = applyCostInBucket( + bucket, + accountingState, + actualCost, + type, + Optional.of(fingerprint) + ); + return reconciliation( + command, + reservation, + snapshot, + tokenEstimate, + actualTokens, + actualCost, + outcome, + reason + ); + } + + private Cost calculateActualCost( + ActualUsageCommand command, + PricingSnapshot snapshot + ) { + Cost actualCost = costCalculator.calculate(command.usage(), snapshot); + if (!snapshot.currency().equals(actualCost.currency())) { + throw new IllegalStateException( + "calculated cost must use the pricing snapshot currency" + ); + } + return actualCost; + } + + private static ReservationReconciliation reconciliation( + ActualUsageCommand command, + BudgetReservation reservation, + PricingSnapshot snapshot, + ReservationTokenEstimate tokenEstimate, + ReservationActualTokens actualTokens, + Cost actualCost, + AccountingTransitionOutcome outcome, + ReservationAccountingReason reason + ) { + return new ReservationReconciliation( + command.requestId(), + command.attemptId(), + reservation.id(), + reservation.key(), + command.responseModelId(), + snapshot, + tokenEstimate, + actualTokens, + reservation.amount(), + actualCost, + outcome.overLimit(), + outcome.transition(), + reason + ); + } + + private static AccountingTransitionOutcome outcome( + Bucket bucket, + ReservationTransition transition + ) { + boolean overLimit = bucket.effectiveUsage().compareTo(bucket.limit) > 0; + return new AccountingTransitionOutcome(transition, overLimit); + } + + private ReservationId reserveOrReturnExisting( + BudgetReservationRequest request, + ReservationId existingReservationId, + AtomicReference result + ) { + if (existingReservationId != null) { + result.set(resultForExistingReservation(existingReservationId, request)); + return existingReservationId; + } + + BudgetReservationResult reservationResult = reserveNewRequest(request); + result.set(reservationResult); + return reservationResult.reservationId(); + } + + private BudgetReservationResult resultForExistingReservation( + ReservationId existingReservationId, + BudgetReservationRequest request + ) { + Bucket bucket = bucketFor(existingReservationId); + synchronized (bucket) { + BudgetReservation existing = accountingState( + bucket, + existingReservationId + ).reservation(); + BudgetSnapshot existingSnapshot = bucket.snapshot(existing.key()); + if (existing.matches(request)) { + return BudgetReservationResult.reused(existing, existingSnapshot); + } + return BudgetReservationResult.conflict( + existing, + existingSnapshot, + "동일 idempotency key에 다른 예약 요청이 사용되었습니다" + ); + } + } + + private BudgetReservationResult reserveNewRequest(BudgetReservationRequest request) { + if (!request.limit().currency().equals(request.safeUpperBoundCost().currency())) { + return BudgetReservationResult.currencyMismatch( + BudgetSnapshot.empty(request.key(), request.limit()) + ); + } + + Bucket bucket = store.computeIfAbsent( + request.key(), + ignored -> new Bucket(request.limit()) + ); + synchronized (bucket) { + return reserveInBucket(bucket, request); + } + } + + private BudgetReservationResult reserveInBucket( + Bucket bucket, + BudgetReservationRequest request + ) { + if (!bucket.limit.currency().equals(request.limit().currency())) { + return BudgetReservationResult.currencyMismatch( + bucket.snapshot(request.key()) + ); + } + if (!bucket.limit.equals(request.limit())) { + return BudgetReservationResult.conflict( + null, + bucket.snapshot(request.key()), + "기존 budget bucket의 limit snapshot이 변경되었습니다" + ); + } + + Cost projectedUsage = bucket.effectiveUsage().add(request.safeUpperBoundCost()); + if (projectedUsage.compareTo(request.limit()) >= 0) { + return BudgetReservationResult.blocked( + bucket.snapshot(request.key()), + "예약 후 사용량이 예산 한도에 도달하거나 초과합니다" + ); + } + + return createReservation(bucket, request); + } + + private BudgetReservationResult createReservation( + Bucket bucket, + BudgetReservationRequest request + ) { + ReservationId reservationId = Objects.requireNonNull( + reservationIdGenerator.get(), + "reservationIdGenerator returned null" + ); + BudgetReservation reservation = BudgetReservation.reserved( + reservationId, + request, + clock.instant() + ); + if (bucket.reservationsById.containsKey(reservationId)) { + throw duplicateReservationId(); + } + if (reservationIndex.putIfAbsent(reservationId, request.key()) != null) { + throw duplicateReservationId(); + } + + bucket.activeReservedCost = bucket.activeReservedCost.add( + request.safeUpperBoundCost() + ); + bucket.reservationsById.put( + reservationId, + ReservationAccountingState.reserved(reservation) + ); + return BudgetReservationResult.created( + reservation, + bucket.snapshot(request.key()) + ); + } + + private static IllegalStateException duplicateReservationId() { + return new IllegalStateException( + "reservationIdGenerator returned a duplicate reservation id" + ); + } + + private ReservationTransition updateState( + ReservationId reservationId, + java.util.function.Function transitionRule + ) { + Objects.requireNonNull(transitionRule, "transitionRule must not be null"); + Bucket bucket = bucketFor(reservationId); + synchronized (bucket) { + ReservationAccountingState accountingState = accountingState( + bucket, + reservationId + ); + BudgetReservation reservation = accountingState.reservation(); + ReservationTransition transition = Objects.requireNonNull( + transitionRule.apply(reservation.state()), + "transitionRule must return a transition" + ); + if (transition.status().isApplied()) { + BudgetReservation updated = withState( + reservation, + transition.resultingState() + ); + replaceReservationSnapshot( + bucket, + accountingState, + accountingState.withReservation(updated) + ); + } + return transition; + } + } + + private boolean commitActiveReservation( + Bucket bucket, + ReservationAccountingState accountingState, + Cost actualCost, + ReservationState resultingState, + Optional fingerprint + ) { + BudgetReservation reservation = accountingState.reservation(); + Cost remainingReserved = subtract( + bucket.activeReservedCost, + reservation.amount() + ); + Cost committed = bucket.committedCost.add(actualCost); + BudgetReservation updated = withState(reservation, resultingState); + + boolean overLimit = remainingReserved + .add(bucket.pendingReconciliationLiability) + .add(committed) + .compareTo(bucket.limit) > 0; + ReservationAccountingState updatedAccountingState = fingerprint + .map(appliedFingerprint -> accountingState.committed( + updated, + actualCost, + overLimit, + appliedFingerprint + )) + .orElseGet(() -> accountingState.committed(updated, actualCost)); + + bucket.activeReservedCost = remainingReserved; + bucket.committedCost = committed; + replaceReservationSnapshot( + bucket, + accountingState, + updatedAccountingState + ); + return overLimit; + } + + private void releaseActiveReservation( + Bucket bucket, + ReservationAccountingState accountingState, + ReservationAccountingState updatedAccountingState + ) { + BudgetReservation reservation = accountingState.reservation(); + Cost remainingReserved = subtract( + bucket.activeReservedCost, + reservation.amount() + ); + + bucket.activeReservedCost = remainingReserved; + replaceReservationSnapshot( + bucket, + accountingState, + updatedAccountingState + ); + } + + private void moveActiveReservationToPending( + Bucket bucket, + ReservationAccountingState accountingState, + ReservationState resultingState + ) { + BudgetReservation reservation = accountingState.reservation(); + Cost remainingReserved = subtract( + bucket.activeReservedCost, + reservation.amount() + ); + Cost pending = bucket.pendingReconciliationLiability.add( + reservation.amount() + ); + BudgetReservation updated = withState(reservation, resultingState); + + bucket.activeReservedCost = remainingReserved; + bucket.pendingReconciliationLiability = pending; + replaceReservationSnapshot( + bucket, + accountingState, + accountingState.withReservation(updated) + ); + } + + private boolean commitPendingReservation( + Bucket bucket, + ReservationAccountingState accountingState, + Cost actualCost, + ReservationState resultingState, + Optional fingerprint + ) { + BudgetReservation reservation = accountingState.reservation(); + Cost remainingPending = subtract( + bucket.pendingReconciliationLiability, + reservation.amount() + ); + Cost committed = bucket.committedCost.add(actualCost); + BudgetReservation updated = withState(reservation, resultingState); + + boolean overLimit = bucket.activeReservedCost + .add(remainingPending) + .add(committed) + .compareTo(bucket.limit) > 0; + ReservationAccountingState updatedAccountingState = fingerprint + .map(appliedFingerprint -> accountingState.lateActualCommitted( + updated, + actualCost, + overLimit, + appliedFingerprint + )) + .orElseGet( + () -> accountingState.lateActualCommitted( + updated, + actualCost + ) + ); + + bucket.pendingReconciliationLiability = remainingPending; + bucket.committedCost = committed; + replaceReservationSnapshot( + bucket, + accountingState, + updatedAccountingState + ); + return overLimit; + } + + private void writeOffPendingReservation( + Bucket bucket, + ReservationAccountingState accountingState, + ReservationState resultingState + ) { + BudgetReservation reservation = accountingState.reservation(); + Cost remainingPending = subtract( + bucket.pendingReconciliationLiability, + reservation.amount() + ); + BudgetReservation updated = withState(reservation, resultingState); + + bucket.pendingReconciliationLiability = remainingPending; + replaceReservationSnapshot( + bucket, + accountingState, + accountingState.withReservation(updated) + ); + } + + private static void requireReconciliationRequiredReason( + ReservationAccountingReason reason + ) { + Objects.requireNonNull(reason, "reason must not be null"); + if (reason != ReservationAccountingReason.ACTUAL_USAGE_UNAVAILABLE + && reason != ReservationAccountingReason.CALLBACK_TIMED_OUT) { + throw new IllegalArgumentException( + "reason is not valid for markReconciliationRequired" + ); + } + } + + private static void requireWriteOffReason(ReservationAccountingReason reason) { + Objects.requireNonNull(reason, "reason must not be null"); + if (reason != ReservationAccountingReason.MANUAL_WRITE_OFF + && reason != ReservationAccountingReason.ACTUAL_USAGE_UNRECOVERABLE) { + throw new IllegalArgumentException("reason is not valid for writeOff"); + } + } + + private void replaceReservationSnapshot( + Bucket bucket, + ReservationAccountingState previous, + ReservationAccountingState updated + ) { + BudgetReservation previousReservation = previous.reservation(); + bucket.reservationsById.put(previousReservation.id(), updated); + } + + private Bucket bucketFor(ReservationId reservationId) { + Objects.requireNonNull(reservationId, "reservationId must not be null"); + BudgetKey key = reservationIndex.get(reservationId); + if (key == null) { + throw new IllegalArgumentException("reservation does not exist"); + } + return Objects.requireNonNull(store.get(key), "reservation bucket must exist"); + } + + private static ReservationAccountingState accountingState( + Bucket bucket, + ReservationId reservationId + ) { + return Objects.requireNonNull( + bucket.reservationsById.get(reservationId), + "reservation must exist in its bucket" + ); + } + + private static Cost subtract(Cost total, Cost amount) { + if (total.compareTo(amount) < 0) { + throw new IllegalStateException("reserved cost must not become negative"); + } + return Cost.of(total.value().subtract(amount.value()), total.currency()); + } + + private static BudgetReservation withState( + BudgetReservation reservation, + ReservationState state + ) { + return new BudgetReservation( + reservation.id(), + reservation.key(), + reservation.limit(), + reservation.amount(), + reservation.requestId(), + reservation.idempotencyKey(), + reservation.modelId(), + reservation.pricingPolicyId(), + reservation.catalogVersion(), + reservation.pricingSnapshot(), + reservation.tokenEstimate(), + state, + reservation.createdAt() + ); + } + private static final class Bucket { private final Cost limit; private Cost committedCost; private Cost activeReservedCost; private Cost pendingReconciliationLiability; - private final Map reservationsById = + private final Map reservationsById = new LinkedHashMap<>(); private Bucket(Cost limit) { @@ -226,8 +990,30 @@ private BudgetSnapshot snapshot(BudgetKey key) { committedCost, activeReservedCost, pendingReconciliationLiability, - reservationsById.keySet() + activeReservationIds() ); } + + private Set activeReservationIds() { + return reservationsById.entrySet().stream() + .filter(entry -> { + ReservationState state = entry.getValue().reservation().state(); + return state == ReservationState.RESERVED + || state == ReservationState.IN_FLIGHT; + }) + .map(Map.Entry::getKey) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } + } + + private record AccountingTransitionOutcome( + ReservationTransition transition, + boolean overLimit + ) { + + private AccountingTransitionOutcome { + Objects.requireNonNull(transition, "transition must not be null"); + } } + } diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java index 438df18..d0380c1 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java @@ -3,9 +3,14 @@ import io.tokenpilot.budget.BudgetEvaluator; import io.tokenpilot.budget.BudgetPolicy; import io.tokenpilot.budget.BudgetStateStore; +import io.tokenpilot.budget.ReservationAccounting; +import io.tokenpilot.budget.ReservationAccountingListener; import io.tokenpilot.budget.ReservationId; +import io.tokenpilot.core.CostCalculator; import java.time.Clock; +import java.util.List; +import java.util.Objects; import java.util.function.Supplier; /** @@ -27,6 +32,47 @@ public static BudgetStateStore inMemoryBudgetStateStore( return new InMemoryBudgetStateStore(clock, reservationIdGenerator); } + public static BudgetStateStore inMemoryBudgetStateStore( + Clock clock, + Supplier reservationIdGenerator, + CostCalculator costCalculator + ) { + return new InMemoryBudgetStateStore( + clock, + reservationIdGenerator, + costCalculator + ); + } + + public static BudgetStateStore inMemoryBudgetStateStore( + Clock clock, + Supplier reservationIdGenerator, + CostCalculator costCalculator, + List accountingListeners + ) { + return new InMemoryBudgetStateStore( + clock, + reservationIdGenerator, + costCalculator, + accountingListeners + ); + } + + /** + * 예약을 생성한 store와 동일한 객체의 회계 명령 진입점을 반환합니다. + */ + public static ReservationAccounting reservationAccounting( + BudgetStateStore stateStore + ) { + Objects.requireNonNull(stateStore, "stateStore must not be null"); + if (stateStore instanceof ReservationAccounting accounting) { + return accounting; + } + throw new IllegalArgumentException( + "stateStore must support reservation accounting" + ); + } + public static BudgetEvaluator defaultBudgetEvaluator( BudgetStateStore store, BudgetPolicy policy, diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReleaseType.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReleaseType.java new file mode 100644 index 0000000..79f5fe1 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReleaseType.java @@ -0,0 +1,7 @@ +package io.tokenpilot.budget.internal; + +/** 예약 금액이 해제된 경로입니다. */ +enum ReleaseType { + BEFORE_DISPATCH, + CONFIRMED_UNBILLED +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java new file mode 100644 index 0000000..80fbaee --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/ReservationAccountingState.java @@ -0,0 +1,271 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.budget.BudgetReservation; +import io.tokenpilot.budget.ReservationStateMachine; +import io.tokenpilot.budget.ReservationTransition; +import io.tokenpilot.core.domain.Cost; + +import java.util.Objects; +import java.util.Optional; + +import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT; +import static io.tokenpilot.budget.AccountingTransitionStatus.CURRENCY_MISMATCH; +import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED; +import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED; +import static io.tokenpilot.budget.ReservationState.WRITTEN_OFF; + +/** + * 한 예약에 적용된 회계 명령을 판단하기 위한 내부 상태입니다. + */ +final class ReservationAccountingState { + + private final BudgetReservation reservation; + private final Optional appliedCommit; + private final Optional appliedRelease; + + private ReservationAccountingState( + BudgetReservation reservation, + Optional appliedCommit, + Optional appliedRelease + ) { + this.reservation = Objects.requireNonNull( + reservation, + "reservation must not be null" + ); + this.appliedCommit = Objects.requireNonNull( + appliedCommit, + "appliedCommit must not be null" + ); + this.appliedRelease = Objects.requireNonNull( + appliedRelease, + "appliedRelease must not be null" + ); + if (appliedCommit.isPresent() && appliedRelease.isPresent()) { + throw new IllegalArgumentException("only one terminal command can be applied"); + } + } + + static ReservationAccountingState reserved(BudgetReservation reservation) { + return new ReservationAccountingState( + reservation, + Optional.empty(), + Optional.empty() + ); + } + + BudgetReservation reservation() { + return reservation; + } + + ReservationTransition evaluateCommit( + Cost actualCost, + Optional fingerprint + ) { + Objects.requireNonNull(actualCost, "actualCost must not be null"); + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + if (hasDifferentCurrency(actualCost)) { + return ReservationTransition.unchanged( + reservation.state(), + CURRENCY_MISMATCH + ); + } + if (appliedRelease.isPresent()) { + return ReservationTransition.unchanged(reservation.state(), CONFLICT); + } + return appliedCommit + .map(commit -> commit.evaluate( + CommitType.DIRECT, + reservation.state(), + actualCost, + fingerprint + )) + .orElseGet(() -> ReservationStateMachine.commit(reservation.state())); + } + + ReservationTransition evaluateLateActual( + Cost actualCost, + Optional fingerprint + ) { + Objects.requireNonNull(actualCost, "actualCost must not be null"); + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + if (hasDifferentCurrency(actualCost)) { + return ReservationTransition.unchanged( + reservation.state(), + CURRENCY_MISMATCH + ); + } + if (reservation.state() == WRITTEN_OFF) { + return ReservationTransition.unchanged(WRITTEN_OFF, CONFLICT); + } + if (appliedRelease.isPresent()) { + return ReservationTransition.unchanged(reservation.state(), CONFLICT); + } + return appliedCommit + .map(commit -> commit.evaluate( + CommitType.LATE_ACTUAL, + reservation.state(), + actualCost, + fingerprint + )) + .orElseGet( + () -> ReservationStateMachine.reconcileLateActual( + reservation.state() + ) + ); + } + + ReservationTransition evaluateReleaseBeforeDispatch() { + if (appliedCommit.isPresent()) { + return ReservationTransition.unchanged(reservation.state(), CONFLICT); + } + return appliedRelease + .map(release -> release.evaluate( + ReleaseType.BEFORE_DISPATCH, + reservation.state() + )) + .orElseGet(() -> ReservationStateMachine.release(reservation.state())); + } + + ReservationTransition evaluateConfirmedUnbilledRelease() { + if (appliedCommit.isPresent()) { + return ReservationTransition.unchanged(reservation.state(), CONFLICT); + } + return appliedRelease + .map(release -> release.evaluate( + ReleaseType.CONFIRMED_UNBILLED, + reservation.state() + )) + .orElseGet( + () -> ReservationStateMachine.releaseConfirmedUnbilled( + reservation.state() + ) + ); + } + + ReservationTransition evaluateWriteOff() { + if (reservation.state() == WRITTEN_OFF) { + return ReservationTransition.unchanged(WRITTEN_OFF, REUSED); + } + if (reservation.state().isClosed()) { + return ReservationTransition.unchanged( + reservation.state(), + CONFLICT + ); + } + return ReservationStateMachine.writeOff(reservation.state()); + } + + ReservationTransition evaluateReconciliationRequired() { + if (reservation.state() == RECONCILIATION_REQUIRED) { + return ReservationTransition.unchanged( + reservation.state(), + REUSED + ); + } + return ReservationStateMachine.markReconciliationRequired( + reservation.state() + ); + } + + ReservationAccountingState withReservation(BudgetReservation updatedReservation) { + return new ReservationAccountingState( + updatedReservation, + appliedCommit, + appliedRelease + ); + } + + ReservationAccountingState releasedBeforeDispatch( + BudgetReservation updatedReservation + ) { + return new ReservationAccountingState( + updatedReservation, + Optional.empty(), + Optional.of(AppliedRelease.beforeDispatch()) + ); + } + + ReservationAccountingState confirmedUnbilledReleased( + BudgetReservation updatedReservation + ) { + return new ReservationAccountingState( + updatedReservation, + Optional.empty(), + Optional.of(AppliedRelease.confirmedUnbilled()) + ); + } + + ReservationAccountingState committed( + BudgetReservation updatedReservation, + Cost actualCost + ) { + return new ReservationAccountingState( + updatedReservation, + Optional.of(AppliedCommit.costOnly(CommitType.DIRECT, actualCost)), + Optional.empty() + ); + } + + ReservationAccountingState lateActualCommitted( + BudgetReservation updatedReservation, + Cost actualCost + ) { + return new ReservationAccountingState( + updatedReservation, + Optional.of(AppliedCommit.costOnly( + CommitType.LATE_ACTUAL, + actualCost + )), + Optional.empty() + ); + } + + Optional reusedCommit( + CommitType type, + ActualUsageFingerprint fingerprint + ) { + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(fingerprint, "fingerprint must not be null"); + return appliedCommit.filter(commit -> commit.matches(type, fingerprint)); + } + + ReservationAccountingState committed( + BudgetReservation updatedReservation, + Cost actualCost, + boolean overLimit, + ActualUsageFingerprint fingerprint + ) { + return new ReservationAccountingState( + updatedReservation, + Optional.of(AppliedCommit.fromCallback( + CommitType.DIRECT, + actualCost, + overLimit, + fingerprint + )), + Optional.empty() + ); + } + + ReservationAccountingState lateActualCommitted( + BudgetReservation updatedReservation, + Cost actualCost, + boolean overLimit, + ActualUsageFingerprint fingerprint + ) { + return new ReservationAccountingState( + updatedReservation, + Optional.of(AppliedCommit.fromCallback( + CommitType.LATE_ACTUAL, + actualCost, + overLimit, + fingerprint + )), + Optional.empty() + ); + } + + private boolean hasDifferentCurrency(Cost actualCost) { + return !reservation.amount().currency().equals(actualCost.currency()); + } +} diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/AccountingTransitionStatusTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/AccountingTransitionStatusTest.java new file mode 100644 index 0000000..352b91a --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/AccountingTransitionStatusTest.java @@ -0,0 +1,25 @@ +package io.tokenpilot.budget; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class AccountingTransitionStatusTest { + + @Test + @DisplayName("APPLIED만 새로운 회계 변경을 뜻한다") + void onlyAppliedMeansANewAccountingChange() { + assertThat(AccountingTransitionStatus.APPLIED.isApplied()).isTrue(); + } + + @Test + @DisplayName("APPLIED 외의 결과는 회계 상태를 변경하지 않는다") + void statusesOtherThanAppliedDoNotChangeAccountingState() { + assertThat(Arrays.stream(AccountingTransitionStatus.values()) + .filter(status -> status != AccountingTransitionStatus.APPLIED)) + .allMatch(status -> !status.isApplied()); + } +} diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java new file mode 100644 index 0000000..f642e25 --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateMachineTest.java @@ -0,0 +1,130 @@ +package io.tokenpilot.budget; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static io.tokenpilot.budget.AccountingTransitionStatus.APPLIED; +import static io.tokenpilot.budget.AccountingTransitionStatus.NOT_ALLOWED; +import static io.tokenpilot.budget.ReservationState.COMMITTED; +import static io.tokenpilot.budget.ReservationState.IN_FLIGHT; +import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED; +import static io.tokenpilot.budget.ReservationState.RELEASED; +import static io.tokenpilot.budget.ReservationState.RESERVED; +import static io.tokenpilot.budget.ReservationState.WRITTEN_OFF; +import static org.assertj.core.api.Assertions.assertThat; + +class ReservationStateMachineTest { + + @Test + @DisplayName("예약된 요청의 호출을 시작하면 진행 중 상태가 된다") + void movesReservedRequestInFlightOnDispatch() { + var transition = ReservationStateMachine.onDispatch(RESERVED); + + assertThat(transition.previousState()).isEqualTo(RESERVED); + assertThat(transition.resultingState()).isEqualTo(IN_FLIGHT); + assertThat(transition.status()).isEqualTo(APPLIED); + } + + @ParameterizedTest + @EnumSource(value = ReservationState.class, names = "RESERVED", mode = EnumSource.Mode.EXCLUDE) + @DisplayName("예약 상태가 아닌 요청에는 호출 시작을 적용하지 않는다") + void rejectsDispatchUnlessReserved(ReservationState currentState) { + var transition = ReservationStateMachine.onDispatch(currentState); + + assertThat(transition.previousState()).isEqualTo(currentState); + assertThat(transition.resultingState()).isEqualTo(currentState); + assertThat(transition.status()).isEqualTo(NOT_ALLOWED); + } + + @Test + @DisplayName("호출 전에 예약을 취소하면 해제 상태가 된다") + void releasesReservedRequestBeforeDispatch() { + var transition = ReservationStateMachine.release(RESERVED); + + assertThat(transition.previousState()).isEqualTo(RESERVED); + assertThat(transition.resultingState()).isEqualTo(RELEASED); + assertThat(transition.status()).isEqualTo(APPLIED); + } + + @Test + @DisplayName("호출을 시작한 예약은 미과금 확인 없이 해제하지 않는다") + void rejectsReleaseAfterDispatch() { + var transition = ReservationStateMachine.release(IN_FLIGHT); + + assertThat(transition.previousState()).isEqualTo(IN_FLIGHT); + assertThat(transition.resultingState()).isEqualTo(IN_FLIGHT); + assertThat(transition.status()).isEqualTo(NOT_ALLOWED); + } + + @Test + @DisplayName("provider가 미과금을 확인하면 진행 중인 예약을 해제한다") + void releasesInFlightReservationAfterNoChargeIsConfirmed() { + var transition = ReservationStateMachine.releaseConfirmedUnbilled(IN_FLIGHT); + + assertThat(transition.previousState()).isEqualTo(IN_FLIGHT); + assertThat(transition.resultingState()).isEqualTo(RELEASED); + assertThat(transition.status()).isEqualTo(APPLIED); + } + + @Test + @DisplayName("호출 후 actual을 전달해 commit하면 비용이 확정된다") + void commitsInFlightReservationWithActual() { + var transition = ReservationStateMachine.commit(IN_FLIGHT); + + assertThat(transition.previousState()).isEqualTo(IN_FLIGHT); + assertThat(transition.resultingState()).isEqualTo(COMMITTED); + assertThat(transition.status()).isEqualTo(APPLIED); + } + + @Test + @DisplayName("actual을 확보하지 못해 정산 대기를 요청하면 정산 대기 상태가 된다") + void marksReconciliationRequiredWithoutActual() { + var transition = ReservationStateMachine.markReconciliationRequired(IN_FLIGHT); + + assertThat(transition.previousState()).isEqualTo(IN_FLIGHT); + assertThat(transition.resultingState()).isEqualTo(RECONCILIATION_REQUIRED); + assertThat(transition.status()).isEqualTo(APPLIED); + } + + @Test + @DisplayName("late actual을 전달해 reconcile하면 비용이 확정된다") + void reconcilesReservationWithLateActual() { + var transition = ReservationStateMachine.reconcileLateActual(RECONCILIATION_REQUIRED); + + assertThat(transition.previousState()).isEqualTo(RECONCILIATION_REQUIRED); + assertThat(transition.resultingState()).isEqualTo(COMMITTED); + assertThat(transition.status()).isEqualTo(APPLIED); + } + + @Test + @DisplayName("정산 대기 중 write-off를 결정하면 상각 상태가 된다") + void writesOffReservationAwaitingReconciliation() { + var transition = ReservationStateMachine.writeOff(RECONCILIATION_REQUIRED); + + assertThat(transition.previousState()).isEqualTo(RECONCILIATION_REQUIRED); + assertThat(transition.resultingState()).isEqualTo(WRITTEN_OFF); + assertThat(transition.status()).isEqualTo(APPLIED); + } + + @Test + @DisplayName("상태 정보만으로 종료 명령 재사용을 판단하지 않는다") + void doesNotInferTerminalReplayFromStateAlone() { + var transition = ReservationStateMachine.commit(COMMITTED); + + assertThat(transition.previousState()).isEqualTo(COMMITTED); + assertThat(transition.resultingState()).isEqualTo(COMMITTED); + assertThat(transition.status()).isEqualTo(NOT_ALLOWED); + } + + @Test + @DisplayName("상태 정보만으로 종료 명령 충돌을 판단하지 않는다") + void doesNotInferTerminalConflictFromStateAlone() { + var transition = ReservationStateMachine.release(COMMITTED); + + assertThat(transition.previousState()).isEqualTo(COMMITTED); + assertThat(transition.resultingState()).isEqualTo(COMMITTED); + assertThat(transition.status()).isEqualTo(NOT_ALLOWED); + } +} diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateTest.java new file mode 100644 index 0000000..886da95 --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationStateTest.java @@ -0,0 +1,25 @@ +package io.tokenpilot.budget; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ReservationStateTest { + + @Test + @DisplayName("정산 대기 상태는 종료 상태가 아니다") + void pendingReconciliationStatesAreNotClosed() { + assertThat(ReservationState.RESERVED.isClosed()).isFalse(); + assertThat(ReservationState.IN_FLIGHT.isClosed()).isFalse(); + assertThat(ReservationState.RECONCILIATION_REQUIRED.isClosed()).isFalse(); + } + + @Test + @DisplayName("확정, 해제, 상각 상태만 종료 상태다") + void committedReleasedAndWrittenOffStatesAreClosed() { + assertThat(ReservationState.COMMITTED.isClosed()).isTrue(); + assertThat(ReservationState.RELEASED.isClosed()).isTrue(); + assertThat(ReservationState.WRITTEN_OFF.isClosed()).isTrue(); + } +} diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationTransitionTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationTransitionTest.java new file mode 100644 index 0000000..9a158eb --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/ReservationTransitionTest.java @@ -0,0 +1,27 @@ +package io.tokenpilot.budget; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static io.tokenpilot.budget.AccountingTransitionStatus.APPLIED; +import static io.tokenpilot.budget.AccountingTransitionStatus.NOT_ALLOWED; +import static io.tokenpilot.budget.ReservationState.IN_FLIGHT; +import static io.tokenpilot.budget.ReservationState.RESERVED; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReservationTransitionTest { + + @Test + @DisplayName("적용된 전이는 예약 상태를 변경해야 한다") + void rejectsAppliedTransitionWithoutStateChange() { + assertThatThrownBy(() -> new ReservationTransition(RESERVED, RESERVED, APPLIED)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("적용되지 않은 전이는 예약 상태를 유지해야 한다") + void rejectsUnappliedTransitionWithStateChange() { + assertThatThrownBy(() -> new ReservationTransition(RESERVED, IN_FLIGHT, NOT_ALLOWED)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java index 7b74714..2e3cdb4 100644 --- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java @@ -10,6 +10,8 @@ import io.tokenpilot.budget.ReservationStatus; import io.tokenpilot.budget.ReservationState; import io.tokenpilot.core.domain.Cost; +import io.tokenpilot.core.domain.PricingSnapshot; +import io.tokenpilot.core.domain.TokenType; import org.junit.jupiter.api.Test; import java.math.BigDecimal; @@ -17,6 +19,8 @@ import java.time.Instant; import java.time.ZoneOffset; import java.util.Currency; +import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; @@ -64,6 +68,27 @@ class BudgetReservationStoreTest { .containsExactly(result.reservation().id()); } + @Test + void 예약은_요청_시점의_exact_pricing_snapshot을_보관한다() { + InMemoryBudgetStateStore store = store(); + PricingSnapshot snapshot = pricingSnapshot("0.10", "0.20"); + + BudgetReservationResult result = store.checkAndReserve( + new BudgetReservationRequest( + KEY, + LIMIT, + usd("60.00"), + new IdempotencyKey("request-1"), + "gpt-4o-mini", + "pricing-v1", + "catalog-v1", + Optional.of(snapshot) + ) + ); + + assertThat(result.reservation().pricingSnapshot()).contains(snapshot); + } + @Test void safe_upper_bound가_한도에_도달하면_예약하지_않고_BLOCKED를_반환한다() { InMemoryBudgetStateStore store = store(); @@ -319,4 +344,23 @@ private static BudgetReservationRequest request( private static Cost usd(String amount) { return Cost.of(new BigDecimal(amount), USD); } + + private static PricingSnapshot pricingSnapshot( + String promptRate, + String completionRate + ) { + return new PricingSnapshot( + "gpt-4o-mini", + "pricing-v1", + "catalog-v1", + CLOCK.instant(), + Map.of( + TokenType.PROMPT, + new BigDecimal(promptRate), + TokenType.COMPLETION, + new BigDecimal(completionRate) + ), + USD + ); + } } diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java new file mode 100644 index 0000000..3988940 --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationAccountingTest.java @@ -0,0 +1,759 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.budget.BudgetKey; +import io.tokenpilot.budget.BudgetSnapshot; +import io.tokenpilot.budget.BudgetWindow; +import io.tokenpilot.budget.ReservationAccounting; +import io.tokenpilot.budget.ReservationAccountingReason; +import io.tokenpilot.budget.ReservationId; +import io.tokenpilot.budget.ReservationTransition; +import io.tokenpilot.core.domain.Cost; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Currency; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT; +import static io.tokenpilot.budget.AccountingTransitionStatus.CURRENCY_MISMATCH; +import static io.tokenpilot.budget.AccountingTransitionStatus.NOT_ALLOWED; +import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED; +import static io.tokenpilot.budget.ReservationState.COMMITTED; +import static io.tokenpilot.budget.ReservationState.IN_FLIGHT; +import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED; +import static io.tokenpilot.budget.ReservationState.RELEASED; +import static io.tokenpilot.budget.ReservationState.RESERVED; +import static io.tokenpilot.budget.ReservationState.WRITTEN_OFF; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReservationAccountingTest { + + private static final Currency USD = Currency.getInstance("USD"); + private static final Cost LIMIT = usd("100.00"); + private static final BudgetKey KEY = new BudgetKey( + "budget-policy", + "tenant", + "tenant-a", + BudgetWindow.parse("2026-08") + ); + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-15T12:34:56Z"), + ZoneOffset.UTC + ); + + @Test + @DisplayName("없는 예약을 commit하면 bucket 합계를 변경하지 않는다") + void keepsBucketUnchangedWhenReservationDoesNotExist() { + InMemoryBudgetStateStore store = store(); + store.addCost(KEY, LIMIT, usd("10.00")); + + assertThatThrownBy( + () -> store.commitCost(new ReservationId("missing"), usd("40.00")) + ).isInstanceOf(IllegalArgumentException.class); + + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.committedCost()).isEqualTo(usd("10.00")); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("변경 없는 결과는 bucket snapshot을 그대로 유지한다") + void preservesBucketSnapshotForEveryNoOpStatus() { + InMemoryBudgetStateStore reusedStore = store(); + ReservationId reusedReservation = reserveInFlight( + reusedStore, + reusedStore, + usd("60.00") + ); + reusedStore.commitCost(reusedReservation, usd("40.00")); + BudgetSnapshot beforeReused = reusedStore.snapshot(KEY, LIMIT); + + ReservationTransition reused = reusedStore.commitCost( + reusedReservation, + usd("40.00") + ); + + assertThat(reused.status()).isEqualTo(REUSED); + assertThat(reusedStore.snapshot(KEY, LIMIT)).isEqualTo(beforeReused); + + InMemoryBudgetStateStore conflictStore = store(); + ReservationId conflictReservation = reserveInFlight( + conflictStore, + conflictStore, + usd("60.00") + ); + conflictStore.commitCost(conflictReservation, usd("40.00")); + BudgetSnapshot beforeConflict = conflictStore.snapshot(KEY, LIMIT); + + ReservationTransition conflict = conflictStore.commitCost( + conflictReservation, + usd("50.00") + ); + + assertThat(conflict.status()).isEqualTo(CONFLICT); + assertThat(conflictStore.snapshot(KEY, LIMIT)).isEqualTo(beforeConflict); + + InMemoryBudgetStateStore notAllowedStore = store(); + ReservationId notAllowedReservation = reserveInFlight( + notAllowedStore, + notAllowedStore, + usd("60.00") + ); + BudgetSnapshot beforeNotAllowed = notAllowedStore.snapshot(KEY, LIMIT); + + ReservationTransition notAllowed = notAllowedStore.releaseBeforeDispatch( + notAllowedReservation + ); + + assertThat(notAllowed.status()).isEqualTo(NOT_ALLOWED); + assertThat(notAllowedStore.snapshot(KEY, LIMIT)).isEqualTo(beforeNotAllowed); + + InMemoryBudgetStateStore mismatchStore = store(); + ReservationId mismatchReservation = reserveInFlight( + mismatchStore, + mismatchStore, + usd("60.00") + ); + BudgetSnapshot beforeMismatch = mismatchStore.snapshot(KEY, LIMIT); + + ReservationTransition mismatch = mismatchStore.commitCost( + mismatchReservation, + Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW")) + ); + + assertThat(mismatch.status()).isEqualTo(CURRENCY_MISMATCH); + assertThat(mismatchStore.snapshot(KEY, LIMIT)).isEqualTo(beforeMismatch); + } + + @Test + @DisplayName("estimate보다 작은 actual을 commit하면 남은 예약액을 해제한다") + void commitsActualBelowEstimate() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + + ReservationTransition transition = store.commitCost(reservationId, usd("40.00")); + + assertThat(transition) + .isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED)); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + } + + @Test + @DisplayName("estimate와 같은 actual을 commit하면 예약액을 확정 비용으로 옮긴다") + void commitsActualEqualToEstimate() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + + ReservationTransition transition = store.commitCost(reservationId, usd("60.00")); + + assertThat(transition) + .isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED)); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("60.00")); + assertThat(snapshot.activeReservationIds()).isEmpty(); + } + + @Test + @DisplayName("estimate보다 큰 actual을 commit하면 초과 비용까지 모두 반영한다") + void commitsActualAboveEstimate() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + + ReservationTransition transition = store.commitCost(reservationId, usd("80.00")); + + assertThat(transition) + .isEqualTo(ReservationTransition.applied(IN_FLIGHT, COMMITTED)); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("80.00")); + } + + @Test + @DisplayName("actual 통화가 다르면 commit하지 않고 통화 불일치를 반환한다") + void rejectsCommitWhenActualCurrencyDiffers() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + + ReservationTransition transition = store.commitCost( + reservationId, + Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW")) + ); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(IN_FLIGHT, CURRENCY_MISMATCH) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("60.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("같은 actual로 commit을 반복하면 기존 정산을 재사용한다") + void reusesCommitWhenActualIsUnchanged() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.commitCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.commitCost(reservationId, usd("40.00")); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, REUSED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("다른 actual로 commit을 반복하면 기존 정산을 보존하고 충돌을 반환한다") + void rejectsCommitWhenActualHasChanged() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.commitCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.commitCost(reservationId, usd("50.00")); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("commit 이후 release를 요청하면 기존 정산을 보존하고 충돌을 반환한다") + void rejectsReleaseAfterCommit() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.commitCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("commit 이후 호출 전 release를 요청하면 기존 정산을 보존하고 충돌을 반환한다") + void rejectsPreDispatchReleaseAfterCommit() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.commitCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.releaseBeforeDispatch(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("확인된 미과금 release 이후 commit하면 기존 해제를 보존하고 충돌을 반환한다") + void rejectsCommitAfterConfirmedUnbilledRelease() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.releaseConfirmedUnbilled(reservationId); + + ReservationTransition transition = store.commitCost(reservationId, usd("40.00")); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(RELEASED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("actual을 확보하지 못해 정산 대기를 요청하면 estimate를 pending으로 옮긴다") + void movesEstimateToPendingWhenActualIsUnavailable() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + + ReservationTransition transition = store.markReconciliationRequired(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.applied(IN_FLIGHT, RECONCILIATION_REQUIRED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("60.00")); + } + + @Test + @DisplayName("정산 대기 명령을 반복해도 pending은 한 번만 반영한다") + void reusesRepeatedPendingCommand() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + + ReservationTransition repeated = store.markReconciliationRequired( + reservationId, + ReservationAccountingReason.CALLBACK_TIMED_OUT + ); + + assertThat(repeated.status()).isEqualTo(REUSED); + assertThat(store.snapshot(KEY, LIMIT).pendingReconciliationLiability()) + .isEqualTo(usd("60.00")); + } + + @Test + @DisplayName("명령과 관계없는 정산 대기 사유는 상태 변경 전에 거부한다") + void rejectsReasonUnrelatedToPendingReconciliation() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + + assertThatThrownBy( + () -> store.markReconciliationRequired( + reservationId, + ReservationAccountingReason.MANUAL_WRITE_OFF + ) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("reason is not valid for markReconciliationRequired"); + + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()) + .isEqualTo(usd("60.00")); + } + + @Test + @DisplayName("actual이 0이면 비용을 확정하고 actual을 모르면 estimate를 pending으로 유지한다") + void distinguishesZeroActualFromUnavailableActual() { + InMemoryBudgetStateStore zeroActualStore = store(); + ReservationId zeroActualReservation = reserveInFlight( + zeroActualStore, + zeroActualStore, + usd("60.00") + ); + InMemoryBudgetStateStore unavailableActualStore = store(); + ReservationId unavailableActualReservation = reserveInFlight( + unavailableActualStore, + unavailableActualStore, + usd("60.00") + ); + + ReservationTransition zeroActualTransition = zeroActualStore.commitCost( + zeroActualReservation, + usd("0.00") + ); + ReservationTransition unavailableActualTransition = + unavailableActualStore.markReconciliationRequired( + unavailableActualReservation + ); + + assertThat(zeroActualTransition).isEqualTo( + ReservationTransition.applied(IN_FLIGHT, COMMITTED) + ); + BudgetSnapshot zeroActualSnapshot = zeroActualStore.snapshot(KEY, LIMIT); + assertThat(zeroActualSnapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(zeroActualSnapshot.pendingReconciliationLiability()) + .isEqualTo(usd("0.00")); + + assertThat(unavailableActualTransition).isEqualTo( + ReservationTransition.applied(IN_FLIGHT, RECONCILIATION_REQUIRED) + ); + BudgetSnapshot unavailableActualSnapshot = unavailableActualStore.snapshot(KEY, LIMIT); + assertThat(unavailableActualSnapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(unavailableActualSnapshot.pendingReconciliationLiability()) + .isEqualTo(usd("60.00")); + } + + @Test + @DisplayName("late actual을 전달해 reconcile하면 pending을 제거하고 비용을 확정한다") + void reconcilesPendingReservationWithLateActual() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + + ReservationTransition transition = store.reconcileLateActualCost( + reservationId, + usd("40.00") + ); + + assertThat(transition).isEqualTo( + ReservationTransition.applied(RECONCILIATION_REQUIRED, COMMITTED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + } + + @Test + @DisplayName("late actual 통화가 다르면 pending을 유지하고 통화 불일치를 반환한다") + void rejectsLateActualWhenCurrencyDiffers() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + + ReservationTransition transition = store.reconcileLateActualCost( + reservationId, + Cost.of(new BigDecimal("40.00"), Currency.getInstance("KRW")) + ); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged( + RECONCILIATION_REQUIRED, + CURRENCY_MISMATCH + ) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("60.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("같은 late actual로 reconcile을 반복하면 기존 정산을 재사용한다") + void reusesLateActualWhenActualIsUnchanged() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + store.reconcileLateActualCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.reconcileLateActualCost( + reservationId, + usd("40.00") + ); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, REUSED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("다른 late actual로 reconcile을 반복하면 기존 정산을 보존하고 충돌을 반환한다") + void rejectsLateActualWhenActualHasChanged() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + store.reconcileLateActualCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.reconcileLateActualCost( + reservationId, + usd("50.00") + ); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("late actual 정산 이후 일반 commit을 요청하면 기존 정산을 보존하고 충돌을 반환한다") + void rejectsDirectCommitAfterLateActualCommit() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + store.reconcileLateActualCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.commitCost(reservationId, usd("40.00")); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("일반 commit 이후 late actual 정산을 요청하면 기존 정산을 보존하고 충돌을 반환한다") + void rejectsLateActualAfterDirectCommit() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.commitCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.reconcileLateActualCost( + reservationId, + usd("40.00") + ); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("provider 호출 전에 release하면 예약액을 해제한다") + void releasesReservationBeforeDispatch() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserve(store, usd("60.00")); + + ReservationTransition transition = store.releaseBeforeDispatch(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.applied(RESERVED, RELEASED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("release는 제한된 사유로 호출 시점의 해제 행위를 선택한다") + void releasesAccordingToBoundedReason() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserve(store, usd("60.00")); + + ReservationTransition transition = store.release( + reservationId, + ReservationAccountingReason.CANCELLED_BEFORE_DISPATCH + ); + + assertThat(transition) + .isEqualTo(ReservationTransition.applied(RESERVED, RELEASED)); + } + + @Test + @DisplayName("release와 관계없는 제한 사유는 상태 변경 전에 거부한다") + void rejectsReasonUnrelatedToRelease() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserve(store, usd("60.00")); + + assertThatThrownBy( + () -> store.release( + reservationId, + ReservationAccountingReason.ACTUAL_USAGE_UNAVAILABLE + ) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("reason is not valid for release"); + + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()) + .isEqualTo(usd("60.00")); + } + + @Test + @DisplayName("호출 전 release를 반복하면 기존 해제를 재사용한다") + void reusesReleaseBeforeDispatch() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserve(store, usd("60.00")); + store.releaseBeforeDispatch(reservationId); + + ReservationTransition transition = store.releaseBeforeDispatch(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(RELEASED, REUSED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("호출 전 release 이후 확인된 미과금 release를 요청하면 충돌을 반환한다") + void rejectsConfirmedUnbilledReleaseAfterPreDispatchRelease() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserve(store, usd("60.00")); + store.releaseBeforeDispatch(reservationId); + + ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(RELEASED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("provider 호출을 시작한 예약에는 호출 전 release를 적용하지 않는다") + void keepsInFlightReservationWhenPreDispatchReleaseIsRequested() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + + ReservationTransition transition = store.releaseBeforeDispatch(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged( + IN_FLIGHT, + NOT_ALLOWED + ) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("60.00")); + } + + @Test + @DisplayName("provider가 미과금을 확인하면 진행 중인 예약액을 해제한다") + void releasesInFlightReservationWhenProviderConfirmsNoCharge() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + + ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.applied(IN_FLIGHT, RELEASED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("확인된 미과금 release를 반복하면 기존 해제를 재사용한다") + void reusesConfirmedUnbilledRelease() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.releaseConfirmedUnbilled(reservationId); + + ReservationTransition transition = store.releaseConfirmedUnbilled(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(RELEASED, REUSED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("정산 대기 예약을 write-off하면 pending만 제거한다") + void writesOffPendingReservation() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + + ReservationTransition transition = store.writeOff(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.applied(RECONCILIATION_REQUIRED, WRITTEN_OFF) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("write-off를 반복하면 기존 상각을 재사용한다") + void reusesWriteOff() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + store.writeOff(reservationId); + + ReservationTransition transition = store.writeOff(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(WRITTEN_OFF, REUSED) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("write-off 이후 late actual을 요청하면 기존 상각을 보존하고 충돌을 반환한다") + void rejectsLateActualAfterWriteOff() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + store.writeOff(reservationId); + + ReservationTransition transition = store.reconcileLateActualCost( + reservationId, + usd("40.00") + ); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(WRITTEN_OFF, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + @Test + @DisplayName("late actual 정산 이후 write-off를 요청하면 기존 정산을 보존하고 충돌을 반환한다") + void rejectsWriteOffAfterLateActual() { + InMemoryBudgetStateStore store = store(); + ReservationId reservationId = reserveInFlight(store, store, usd("60.00")); + store.markReconciliationRequired(reservationId); + store.reconcileLateActualCost(reservationId, usd("40.00")); + + ReservationTransition transition = store.writeOff(reservationId); + + assertThat(transition).isEqualTo( + ReservationTransition.unchanged(COMMITTED, CONFLICT) + ); + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("0.00")); + assertThat(snapshot.committedCost()).isEqualTo(usd("40.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(usd("0.00")); + } + + private static ReservationId reserveInFlight( + InMemoryBudgetStateStore store, + ReservationAccounting accounting, + Cost estimate + ) { + ReservationId reservationId = reserve(store, estimate); + accounting.markInFlight(reservationId); + return reservationId; + } + + private static ReservationId reserve( + InMemoryBudgetStateStore store, + Cost estimate + ) { + return store.checkAndReserve( + KEY, + LIMIT, + estimate, + "request-1" + ).reservationId(); + } + + private static InMemoryBudgetStateStore store() { + AtomicInteger sequence = new AtomicInteger(); + return new InMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId("reservation-" + sequence.incrementAndGet()) + ); + } + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } +} diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java new file mode 100644 index 0000000..22b3759 --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/ReservationReconciliationTest.java @@ -0,0 +1,770 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.budget.ActualUsageCommand; +import io.tokenpilot.budget.BudgetKey; +import io.tokenpilot.budget.BudgetReservationRequest; +import io.tokenpilot.budget.BudgetStateStore; +import io.tokenpilot.budget.BudgetWindow; +import io.tokenpilot.budget.IdempotencyKey; +import io.tokenpilot.budget.ReservationId; +import io.tokenpilot.budget.ReservationAccounting; +import io.tokenpilot.budget.ReservationAccountingEvent; +import io.tokenpilot.budget.ReservationAccountingReason; +import io.tokenpilot.budget.ReservationActualTokens; +import io.tokenpilot.budget.ReservationReconciliation; +import io.tokenpilot.budget.ReservationStatus; +import io.tokenpilot.budget.ReservationTokenEstimate; +import io.tokenpilot.core.CostCalculator; +import io.tokenpilot.core.domain.Cost; +import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingSnapshot; +import io.tokenpilot.core.domain.TokenType; +import io.tokenpilot.core.domain.TokenUsage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Currency; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static io.tokenpilot.budget.ReservationState.COMMITTED; +import static io.tokenpilot.budget.ReservationState.IN_FLIGHT; +import static io.tokenpilot.budget.ReservationState.RECONCILIATION_REQUIRED; +import static io.tokenpilot.budget.AccountingTransitionStatus.CONFLICT; +import static io.tokenpilot.budget.AccountingTransitionStatus.REUSED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ReservationReconciliationTest { + + private static final Currency USD = Currency.getInstance("USD"); + private static final Cost LIMIT = usd("100.00"); + private static final BudgetKey KEY = new BudgetKey( + "budget-policy", + "tenant", + "tenant-a", + BudgetWindow.parse("2026-08") + ); + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-15T12:34:56Z"), + ZoneOffset.UTC + ); + private static final ReservationTokenEstimate TOKEN_ESTIMATE = + new ReservationTokenEstimate(90, 100, 50); + + @Test + @DisplayName("예약 시점 pricing snapshot으로 actual 비용을 한 번만 계산한다") + void calculatesActualCostOnceWithReservedPricingSnapshot() { + AtomicInteger calculationCount = new AtomicInteger(); + AtomicReference calculatedPlan = new AtomicReference<>(); + CostCalculator calculator = (usage, plan) -> { + calculationCount.incrementAndGet(); + calculatedPlan.set(plan); + return usd("40.00"); + }; + InMemoryBudgetStateStore store = store(calculator); + PricingSnapshot snapshot = pricingSnapshot(); + ReservationId reservationId = reserve(store, snapshot, usd("60.00")); + store.markInFlight(reservationId); + + ReservationReconciliation reconciliation = store.commit( + new ActualUsageCommand( + "request-1", + "attempt-1", + reservationId, + TokenUsage.from(100, 50), + "gpt-4o-mini-response" + ) + ); + + assertThat(calculationCount).hasValue(1); + assertThat(calculatedPlan.get().modelId()).isEqualTo(snapshot.modelId()); + assertThat(calculatedPlan.get().pricingPolicyId()) + .isEqualTo(snapshot.pricingPolicyId()); + assertThat(calculatedPlan.get().rates()).isEqualTo(snapshot.rates()); + assertThat(calculatedPlan.get().currency()).isEqualTo(snapshot.currency()); + assertThat(reconciliation.transition().previousState()).isEqualTo(IN_FLIGHT); + assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED); + assertThat(reconciliation.reason()) + .isEqualTo(ReservationAccountingReason.ACTUAL_USAGE_REPORTED); + } + + @Test + @DisplayName("actual이 estimate보다 작으면 음수 delta를 반환한다") + void returnsNegativeDeltaWhenActualIsBelowEstimate() { + ReservationReconciliation reconciliation = reconcile("60.00", "40.00"); + + assertThat(reconciliation.delta()).isEqualByComparingTo("-20.00"); + } + + @Test + @DisplayName("actual이 estimate와 같으면 0 delta를 반환한다") + void returnsZeroDeltaWhenActualEqualsEstimate() { + ReservationReconciliation reconciliation = reconcile("60.00", "60.00"); + + assertThat(reconciliation.delta()).isEqualByComparingTo("0.00"); + } + + @Test + @DisplayName("actual이 estimate보다 크면 양수 delta를 반환한다") + void returnsPositiveDeltaWhenActualIsAboveEstimate() { + ReservationReconciliation reconciliation = reconcile("60.00", "80.00"); + + assertThat(reconciliation.delta()).isEqualByComparingTo("20.00"); + } + + @Test + @DisplayName("actual 정산으로 limit을 넘으면 결과에 초과 상태를 남긴다") + void reportsOverLimitWhenActualExceedsBudgetLimit() { + ReservationReconciliation reconciliation = reconcile("60.00", "120.00"); + + assertThat(reconciliation.overLimit()).isTrue(); + } + + @Test + @DisplayName("actual 초과로 limit을 넘으면 비용을 반영하고 다음 예약을 차단한다") + void blocksNextReservationAfterActualExceedsLimit() { + InMemoryBudgetStateStore store = store((usage, plan) -> usd("120.00")); + PricingSnapshot snapshot = pricingSnapshot(); + ReservationId reservationId = reserve(store, snapshot, usd("60.00")); + store.markInFlight(reservationId); + + ReservationReconciliation reconciliation = store.commit( + command(reservationId) + ); + var blocked = store.checkAndReserve( + new BudgetReservationRequest( + KEY, + LIMIT, + usd("1.00"), + "request-2", + new IdempotencyKey("deduplication-2"), + snapshot, + TOKEN_ESTIMATE + ) + ); + + assertThat(reconciliation.overLimit()).isTrue(); + assertThat(store.snapshot(KEY, LIMIT).committedCost()) + .isEqualTo(usd("120.00")); + assertThat(blocked.status()).isEqualTo(ReservationStatus.BLOCKED); + } + + @Test + @DisplayName("callback timeout은 estimate를 pending에 유지해 다음 예약에 반영한다") + void keepsTimedOutInFlightLiabilityInAdmission() { + InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00")); + PricingSnapshot snapshot = pricingSnapshot(); + ReservationId reservationId = reserve(store, snapshot, usd("60.00")); + store.markInFlight(reservationId); + + var transition = store.markReconciliationRequired( + reservationId, + ReservationAccountingReason.CALLBACK_TIMED_OUT + ); + var blocked = store.checkAndReserve( + new BudgetReservationRequest( + KEY, + LIMIT, + usd("50.00"), + "request-2", + new IdempotencyKey("deduplication-2"), + snapshot, + TOKEN_ESTIMATE + ) + ); + + assertThat(transition.previousState()).isEqualTo(IN_FLIGHT); + assertThat(transition.resultingState()) + .isEqualTo(RECONCILIATION_REQUIRED); + assertThat(store.snapshot(KEY, LIMIT).pendingReconciliationLiability()) + .isEqualTo(usd("60.00")); + assertThat(blocked.status()).isEqualTo(ReservationStatus.BLOCKED); + } + + @Test + @DisplayName("actual 정산이 limit과 같으면 초과로 표시하지 않는다") + void doesNotReportOverLimitAtExactBudgetLimit() { + ReservationReconciliation reconciliation = reconcile("60.00", "100.00"); + + assertThat(reconciliation.overLimit()).isFalse(); + } + + @Test + @DisplayName("정산 결과는 요청과 시도, 예약, 모델, pricing 정보를 연결한다") + void correlatesRequestReservationModelsAndPricing() { + InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00")); + PricingSnapshot snapshot = pricingSnapshot(); + ReservationId reservationId = reserve(store, snapshot, usd("60.00")); + store.markInFlight(reservationId); + + ReservationReconciliation reconciliation = store.commit( + command(reservationId) + ); + + assertThat(reconciliation.requestId()).isEqualTo("request-1"); + assertThat(reconciliation.attemptId()).isEqualTo("attempt-1"); + assertThat(reconciliation.reservationId()).isEqualTo(reservationId); + assertThat(reconciliation.budgetKey()).isEqualTo(KEY); + assertThat(reconciliation.requestModelId()).isEqualTo(snapshot.modelId()); + assertThat(reconciliation.responseModelId()) + .isEqualTo("gpt-4o-mini-response"); + assertThat(reconciliation.pricingPolicyId()) + .isEqualTo(snapshot.pricingPolicyId()); + assertThat(reconciliation.catalogVersion()) + .isEqualTo(snapshot.catalogVersion()); + assertThat(reconciliation.estimate()).isEqualTo(usd("60.00")); + assertThat(reconciliation.actual()).isEqualTo(usd("40.00")); + assertThat(reconciliation.tokenEstimate()).isEqualTo(TOKEN_ESTIMATE); + assertThat(reconciliation.actualTokens()) + .isEqualTo(ReservationActualTokens.from(TokenUsage.from(100, 50))); + assertThat(reconciliation.inputTokenDelta()).isEqualTo(10); + assertThat(reconciliation.outputTokenDelta()).isZero(); + assertThat(reconciliation.totalTokenDelta()).isEqualTo(10); + assertThat(reconciliation.currency()).isEqualTo(USD); + assertThat(reconciliation.transition().previousState()).isEqualTo(IN_FLIGHT); + assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED); + assertThat(reconciliation.reason()) + .isEqualTo(ReservationAccountingReason.ACTUAL_USAGE_REPORTED); + } + + @Test + @DisplayName("request ID와 idempotency key가 달라도 예약된 요청을 정산한다") + void reconcilesWhenRequestIdDiffersFromIdempotencyKey() { + InMemoryBudgetStateStore store = store((usage, plan) -> usd("40.00")); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd("60.00") + ); + store.markInFlight(reservationId); + + ReservationReconciliation reconciliation = store.commit( + command(reservationId) + ); + + assertThat(reconciliation.requestId()).isEqualTo("request-1"); + } + + @Test + @DisplayName("late actual도 예약 시점 가격으로 한 번 계산해 pending을 정산한다") + void calculatesLateActualOnceAndReconcilesPendingReservation() { + AtomicInteger calculationCount = new AtomicInteger(); + InMemoryBudgetStateStore store = store((usage, plan) -> { + calculationCount.incrementAndGet(); + return usd("40.00"); + }); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd("60.00") + ); + store.markInFlight(reservationId); + store.markReconciliationRequired(reservationId); + + ReservationReconciliation reconciliation = store.reconcileLateActual( + command(reservationId) + ); + + assertThat(calculationCount).hasValue(1); + assertThat(reconciliation.transition().previousState()) + .isEqualTo(RECONCILIATION_REQUIRED); + assertThat(reconciliation.transition().resultingState()).isEqualTo(COMMITTED); + assertThat(reconciliation.reason()) + .isEqualTo(ReservationAccountingReason.LATE_ACTUAL_USAGE_REPORTED); + assertThat(store.snapshot(KEY, LIMIT).pendingReconciliationLiability()) + .isEqualTo(usd("0.00")); + assertThat(store.snapshot(KEY, LIMIT).committedCost()).isEqualTo(usd("40.00")); + } + + @Test + @DisplayName("pricing snapshot이 없는 예약은 usage 정산 전에 거부한다") + void rejectsUsageReconciliationWithoutReservedPricingSnapshot() { + AtomicInteger calculationCount = new AtomicInteger(); + InMemoryBudgetStateStore store = store((usage, plan) -> { + calculationCount.incrementAndGet(); + return usd("40.00"); + }); + ReservationId reservationId = store.checkAndReserve( + KEY, + LIMIT, + usd("60.00"), + "request-1" + ).reservationId(); + store.markInFlight(reservationId); + var before = store.snapshot(KEY, LIMIT); + + assertThatThrownBy(() -> store.commit(command(reservationId))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("reservation does not contain a pricing snapshot"); + + assertThat(calculationCount).hasValue(0); + assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before); + } + + @Test + @DisplayName("token estimate가 없는 예약은 비용 계산 전에 정산을 거부한다") + void rejectsUsageReconciliationWithoutTokenEstimate() { + AtomicInteger calculationCount = new AtomicInteger(); + InMemoryBudgetStateStore store = store((usage, plan) -> { + calculationCount.incrementAndGet(); + return usd("40.00"); + }); + PricingSnapshot snapshot = pricingSnapshot(); + ReservationId reservationId = store.checkAndReserve( + new BudgetReservationRequest( + KEY, + LIMIT, + usd("60.00"), + "request-1", + new IdempotencyKey("deduplication-1"), + snapshot.modelId(), + snapshot.pricingPolicyId(), + snapshot.catalogVersion(), + Optional.of(snapshot) + ) + ).reservationId(); + store.markInFlight(reservationId); + var before = store.snapshot(KEY, LIMIT); + + assertThatThrownBy(() -> store.commit(command(reservationId))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("reservation does not contain a token estimate"); + + assertThat(calculationCount).hasValue(0); + assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before); + } + + @Test + @DisplayName("다른 request ID는 비용 계산 전에 거부한다") + void rejectsMismatchedRequestIdBeforeCostCalculation() { + AtomicInteger calculationCount = new AtomicInteger(); + InMemoryBudgetStateStore store = store((usage, plan) -> { + calculationCount.incrementAndGet(); + return usd("40.00"); + }); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd("60.00") + ); + store.markInFlight(reservationId); + var before = store.snapshot(KEY, LIMIT); + + assertThatThrownBy( + () -> store.commit( + new ActualUsageCommand( + "different-request", + "attempt-1", + reservationId, + TokenUsage.from(100, 50), + "gpt-4o-mini-response" + ) + ) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("requestId must match the reservation request"); + + assertThat(calculationCount).hasValue(0); + assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before); + } + + @Test + @DisplayName("계산된 actual 통화가 예약 가격 통화와 다르면 상태를 변경하지 않는다") + void rejectsCalculatedCostWithUnexpectedCurrency() { + Currency eur = Currency.getInstance("EUR"); + InMemoryBudgetStateStore store = store( + (usage, plan) -> Cost.of(new BigDecimal("40.00"), eur) + ); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd("60.00") + ); + store.markInFlight(reservationId); + var before = store.snapshot(KEY, LIMIT); + + assertThatThrownBy(() -> store.commit(command(reservationId))) + .isInstanceOf(IllegalStateException.class) + .hasMessage("calculated cost must use the pricing snapshot currency"); + + assertThat(store.snapshot(KEY, LIMIT)).isEqualTo(before); + } + + @Test + @DisplayName("actual usage를 확인할 수 없으면 정산 명령으로 만들지 않는다") + void rejectsUnavailableActualUsage() { + assertThatThrownBy( + () -> new ActualUsageCommand( + "request-1", + "attempt-1", + new ReservationId("reservation-1"), + TokenUsage.unavailable(Map.of()), + "gpt-4o-mini-response" + ) + ).isInstanceOf(IllegalArgumentException.class) + .hasMessage("usage must be available for actual reconciliation"); + } + + @Test + @DisplayName("동일 commit callback은 actual 비용을 다시 계산하지 않는다") + void doesNotRecalculateCostForDuplicateCommitCallback() { + AtomicInteger calculationCount = new AtomicInteger(); + InMemoryBudgetStateStore store = store((usage, plan) -> { + calculationCount.incrementAndGet(); + return usd("40.00"); + }); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd("60.00") + ); + store.markInFlight(reservationId); + + ReservationReconciliation first = store.commit(command(reservationId)); + ReservationReconciliation duplicate = store.commit(command(reservationId)); + + assertThat(calculationCount).hasValue(1); + assertThat(first.transition().status().isApplied()).isTrue(); + assertThat(duplicate.transition().status()).isEqualTo(REUSED); + assertThat(duplicate.actual()).isEqualTo(first.actual()); + assertThat(duplicate.overLimit()).isEqualTo(first.overLimit()); + } + + @Test + @DisplayName("동일 late actual callback은 actual 비용을 다시 계산하지 않는다") + void doesNotRecalculateCostForDuplicateLateActualCallback() { + AtomicInteger calculationCount = new AtomicInteger(); + InMemoryBudgetStateStore store = store((usage, plan) -> { + calculationCount.incrementAndGet(); + return usd("40.00"); + }); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd("60.00") + ); + store.markInFlight(reservationId); + store.markReconciliationRequired(reservationId); + + ReservationReconciliation first = store.reconcileLateActual( + command(reservationId) + ); + ReservationReconciliation duplicate = store.reconcileLateActual( + command(reservationId) + ); + + assertThat(calculationCount).hasValue(1); + assertThat(first.transition().status().isApplied()).isTrue(); + assertThat(duplicate.transition().status()).isEqualTo(REUSED); + } + + @Test + @DisplayName("다른 callback payload는 비용을 계산한 뒤 기존 commit과 충돌한다") + void calculatesChangedCallbackBeforeReportingConflict() { + AtomicInteger calculationCount = new AtomicInteger(); + InMemoryBudgetStateStore store = store((usage, plan) -> { + calculationCount.incrementAndGet(); + return usage.inputTokens() == 100 ? usd("40.00") : usd("50.00"); + }); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd("60.00") + ); + store.markInFlight(reservationId); + store.commit(command(reservationId)); + + ReservationReconciliation conflict = store.commit( + new ActualUsageCommand( + "request-1", + "attempt-1", + reservationId, + TokenUsage.from(101, 50), + "gpt-4o-mini-response" + ) + ); + + assertThat(calculationCount).hasValue(2); + assertThat(conflict.transition().status()).isEqualTo(CONFLICT); + assertThat(store.snapshot(KEY, LIMIT).committedCost()) + .isEqualTo(usd("40.00")); + } + + @Test + @DisplayName("비용이 같아도 callback payload가 다르면 기존 commit과 충돌한다") + void reportsConflictWhenChangedCallbackHasSameCost() { + AtomicInteger calculationCount = new AtomicInteger(); + InMemoryBudgetStateStore store = store((usage, plan) -> { + calculationCount.incrementAndGet(); + return usd("40.00"); + }); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd("60.00") + ); + store.markInFlight(reservationId); + store.commit(command(reservationId)); + + ReservationReconciliation conflict = store.commit( + new ActualUsageCommand( + "request-1", + "attempt-1", + reservationId, + TokenUsage.from(101, 50), + "gpt-4o-mini-response" + ) + ); + + assertThat(calculationCount).hasValue(2); + assertThat(conflict.transition().status()).isEqualTo(CONFLICT); + assertThat(store.snapshot(KEY, LIMIT).committedCost()) + .isEqualTo(usd("40.00")); + } + + @Test + @DisplayName("새롭게 적용된 commit만 회계 이벤트를 한 번 생성한다") + void publishesAccountingEventOnlyForNewlyAppliedCommit() { + List events = new ArrayList<>(); + AtomicInteger sequence = new AtomicInteger(); + BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId( + "reservation-" + sequence.incrementAndGet() + ), + (usage, plan) -> usd("40.00"), + List.of(events::add) + ); + ReservationAccounting accounting = + LedgerBudgetComponents.reservationAccounting(stateStore); + ReservationId reservationId = reserve( + stateStore, + pricingSnapshot(), + usd("60.00") + ); + accounting.markInFlight(reservationId); + + ReservationReconciliation applied = accounting.commit(command(reservationId)); + accounting.commit(command(reservationId)); + accounting.commit( + new ActualUsageCommand( + "request-1", + "attempt-1", + reservationId, + TokenUsage.from(101, 50), + "gpt-4o-mini-response" + ) + ); + + assertThat(events).containsExactly( + new ReservationAccountingEvent(applied) + ); + } + + @Test + @DisplayName("새롭게 적용된 late actual만 회계 이벤트를 한 번 생성한다") + void publishesAccountingEventOnlyForNewlyAppliedLateActual() { + List events = new ArrayList<>(); + AtomicInteger sequence = new AtomicInteger(); + BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId( + "reservation-" + sequence.incrementAndGet() + ), + (usage, plan) -> usd("40.00"), + List.of(events::add) + ); + ReservationAccounting accounting = + LedgerBudgetComponents.reservationAccounting(stateStore); + ReservationId reservationId = reserve( + stateStore, + pricingSnapshot(), + usd("60.00") + ); + accounting.markInFlight(reservationId); + accounting.markReconciliationRequired(reservationId); + + ReservationReconciliation applied = accounting.reconcileLateActual( + command(reservationId) + ); + accounting.reconcileLateActual(command(reservationId)); + + assertThat(events).containsExactly( + new ReservationAccountingEvent(applied) + ); + } + + @Test + @DisplayName("listener 실패는 commit을 되돌리지 않고 중복 callback에서 재시도하지 않는다") + void preservesCommitAndDoesNotRetryEventAfterListenerFailure() { + AtomicInteger deliveryCount = new AtomicInteger(); + AtomicInteger sequence = new AtomicInteger(); + BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId( + "reservation-" + sequence.incrementAndGet() + ), + (usage, plan) -> usd("40.00"), + List.of(event -> { + deliveryCount.incrementAndGet(); + throw new IllegalStateException("listener failed"); + }) + ); + ReservationAccounting accounting = + LedgerBudgetComponents.reservationAccounting(stateStore); + ReservationId reservationId = reserve( + stateStore, + pricingSnapshot(), + usd("60.00") + ); + accounting.markInFlight(reservationId); + + ReservationReconciliation applied = accounting.commit( + command(reservationId) + ); + ReservationReconciliation duplicate = accounting.commit( + command(reservationId) + ); + + assertThat(applied.transition().status().isApplied()).isTrue(); + assertThat(duplicate.transition().status()).isEqualTo(REUSED); + assertThat(deliveryCount).hasValue(1); + assertThat(stateStore.snapshot(KEY, LIMIT).committedCost()) + .isEqualTo(usd("40.00")); + } + + @Test + @DisplayName("한 listener의 실패가 다음 listener의 이벤트 수신을 막지 않는다") + void continuesDeliveryAfterListenerFailure() { + List receivedEvents = new ArrayList<>(); + AtomicInteger failedDeliveryCount = new AtomicInteger(); + AtomicInteger sequence = new AtomicInteger(); + BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId( + "reservation-" + sequence.incrementAndGet() + ), + (usage, plan) -> usd("40.00"), + List.of( + event -> { + failedDeliveryCount.incrementAndGet(); + throw new IllegalStateException("listener failed"); + }, + receivedEvents::add + ) + ); + ReservationAccounting accounting = + LedgerBudgetComponents.reservationAccounting(stateStore); + ReservationId reservationId = reserve( + stateStore, + pricingSnapshot(), + usd("60.00") + ); + accounting.markInFlight(reservationId); + + ReservationReconciliation applied = accounting.commit( + command(reservationId) + ); + + assertThat(failedDeliveryCount).hasValue(1); + assertThat(receivedEvents).containsExactly( + new ReservationAccountingEvent(applied) + ); + } + + @Test + @DisplayName("공개 factory는 예약 store와 같은 객체의 정산 진입점을 반환한다") + void exposesAccountingForTheSameReservationStore() { + AtomicInteger sequence = new AtomicInteger(); + BudgetStateStore stateStore = LedgerBudgetComponents.inMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId( + "reservation-" + sequence.incrementAndGet() + ), + (usage, plan) -> usd("40.00") + ); + ReservationAccounting accounting = + LedgerBudgetComponents.reservationAccounting(stateStore); + + assertThat(accounting).isSameAs(stateStore); + } + + private static ReservationReconciliation reconcile( + String estimate, + String actual + ) { + InMemoryBudgetStateStore store = store((usage, plan) -> usd(actual)); + ReservationId reservationId = reserve( + store, + pricingSnapshot(), + usd(estimate) + ); + store.markInFlight(reservationId); + return store.commit(command(reservationId)); + } + + private static ActualUsageCommand command(ReservationId reservationId) { + return new ActualUsageCommand( + "request-1", + "attempt-1", + reservationId, + TokenUsage.from(100, 50), + "gpt-4o-mini-response" + ); + } + + private static ReservationId reserve( + BudgetStateStore store, + PricingSnapshot snapshot, + Cost estimate + ) { + return store.checkAndReserve( + new BudgetReservationRequest( + KEY, + LIMIT, + estimate, + "request-1", + new IdempotencyKey("deduplication-1"), + snapshot, + TOKEN_ESTIMATE + ) + ).reservationId(); + } + + private static InMemoryBudgetStateStore store(CostCalculator calculator) { + AtomicInteger sequence = new AtomicInteger(); + return new InMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId("reservation-" + sequence.incrementAndGet()), + calculator + ); + } + + private static PricingSnapshot pricingSnapshot() { + return new PricingSnapshot( + "gpt-4o-mini-request", + "pricing-v1", + "catalog-v1", + CLOCK.instant(), + Map.of( + TokenType.PROMPT, + new BigDecimal("0.10"), + TokenType.COMPLETION, + new BigDecimal("0.20") + ), + USD + ); + } + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/CostCalculator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/CostCalculator.java index c20da7c..ca5e8da 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/CostCalculator.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/CostCalculator.java @@ -2,8 +2,11 @@ import io.tokenpilot.core.domain.Cost; import io.tokenpilot.core.domain.PricingPlan; +import io.tokenpilot.core.domain.PricingSnapshot; import io.tokenpilot.core.domain.TokenUsage; +import java.util.Objects; + /** * 사용량과 가격 정책을 바탕으로 비용을 계산하는 인터페이스. */ @@ -15,4 +18,20 @@ public interface CostCalculator { * @return 산출된 비용 */ Cost calculate(TokenUsage usage, PricingPlan plan); + + /** + * 예약 시점에 고정한 가격 snapshot으로 비용을 계산합니다. + */ + default Cost calculate(TokenUsage usage, PricingSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot must not be null"); + return calculate( + usage, + new PricingPlan( + snapshot.modelId(), + snapshot.pricingPolicyId(), + snapshot.rates(), + snapshot.currency() + ) + ); + } }