From e50e36c72509164c8d2397381628a0cb6a4655b8 Mon Sep 17 00:00:00 2001 From: HuitaePark Date: Sat, 15 Aug 2026 21:50:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(budget):=20=EC=9B=90=EC=9E=90=EC=A0=81=20?= =?UTF-8?q?=EC=98=88=EC=82=B0=20=EC=98=88=EC=95=BD=EA=B3=BC=20=EB=A9=B1?= =?UTF-8?q?=EB=93=B1=EC=84=B1=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 13 +- README.md | 12 +- .../tokenpilot/budget/BudgetReservation.java | 79 +++++ .../budget/BudgetReservationRequest.java | 80 +++++ .../budget/BudgetReservationResult.java | 84 +++++ .../io/tokenpilot/budget/BudgetSnapshot.java | 66 ++++ .../tokenpilot/budget/BudgetStateStore.java | 87 +++++ .../io/tokenpilot/budget/IdempotencyKey.java | 18 + .../io/tokenpilot/budget/ReservationId.java | 24 ++ .../tokenpilot/budget/ReservationState.java | 10 + .../tokenpilot/budget/ReservationStatus.java | 12 + .../internal/InMemoryBudgetStateStore.java | 262 +++++++++++--- .../internal/LedgerBudgetComponents.java | 9 + .../internal/BudgetReservationStoreTest.java | 322 ++++++++++++++++++ 14 files changed, 1025 insertions(+), 53 deletions(-) create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationResult.java create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetSnapshot.java create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/IdempotencyKey.java create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationId.java create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java create mode 100644 token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStatus.java create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java diff --git a/AGENTS.md b/AGENTS.md index fd2e99d..209f97c 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, and a plain-Java core consumer verification path are implemented. Candidate-aware request production, atomic reservation, 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, 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. 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. @@ -71,10 +71,10 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac | Module | Status | Notes | | --- | --- | --- | -| `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; atomic reservation and reconciliation are still required | +| `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 non-atomic implementation | Typed monthly keys, Clock/ZoneId windows, and pure status/admission decisions implemented; needs candidate estimation, reservation, idempotency, and reconciliation | +| `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-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 | @@ -328,7 +328,7 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works - Budget money interfaces now use `Cost` while preserving `BudgetKey`, `BudgetPolicy`, Clock/ZoneId monthly windows, and per-key policy snapshots. - 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 an atomic reservation. +- 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. - 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 +400,11 @@ Stage and deploy a Central release: ## Update History +### 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. +- Kept the existing committed-cost APIs compatible while leaving commit/release and estimate/actual reconciliation to #37. + ### 2026-08-14 - Added the immutable versioned `ModelRegistry`/`ModelDefinition` catalog, canonical alias lookup, pricing-policy and currency binding, and the minimal official-source model catalog. diff --git a/README.md b/README.md index 30a80d6..28de07d 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,9 @@ library behind it. TokenPilot 0.1.0 is under active development. The current foundation includes Spring AI usage integration, model cost calculation, metrics, basic budget -policy, and Spring Boot autoconfiguration. The MVP is extending this foundation -with preflight control, atomic budget reservation, and actual usage +policy, Spring Boot autoconfiguration, and a framework-independent in-memory +atomic budget reservation/idempotency foundation. The MVP is extending this +foundation with provider-connected preflight control and actual usage reconciliation. ## Framework-independent core @@ -107,9 +108,10 @@ Boot, Spring AI, Micrometer, or Reactor dependencies. Spring Boot 3, Spring AI are not part of the 0.1.0 support guarantee. The verified Spring AI path is the synchronous `ChatClient` call lifecycle with -a fake provider. Streaming cancellation, preflight blocking, atomic budget -reservation, and estimate/actual reconciliation are still MVP work and are not -included in this compatibility claim. +a fake provider. Streaming cancellation, provider-connected preflight +blocking, reservation lifecycle reconciliation, and estimate/actual +reconciliation are still MVP work and are not included in this compatibility +claim. ## License 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 new file mode 100644 index 0000000..d848ffd --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservation.java @@ -0,0 +1,79 @@ +package io.tokenpilot.budget; + +import io.tokenpilot.core.domain.Cost; + +import java.time.Instant; +import java.util.Objects; + +/** + * 예산 bucket에 생성된 immutable 예약 snapshot입니다. + */ +public record BudgetReservation( + ReservationId id, + BudgetKey key, + Cost limit, + Cost amount, + IdempotencyKey idempotencyKey, + String modelId, + String pricingPolicyId, + String catalogVersion, + ReservationState state, + Instant createdAt +) { + + public BudgetReservation { + Objects.requireNonNull(id, "id must not be null"); + Objects.requireNonNull(key, "key must not be null"); + Objects.requireNonNull(limit, "limit must not be null"); + Objects.requireNonNull(amount, "amount must not be null"); + Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); + Objects.requireNonNull(state, "state must not be null"); + Objects.requireNonNull(createdAt, "createdAt must not be null"); + if (limit.value().signum() <= 0) { + throw new IllegalArgumentException("limit must be greater than zero"); + } + if (!limit.currency().equals(amount.currency())) { + throw new IllegalArgumentException("reservation costs must use the budget currency"); + } + modelId = optionalText(modelId, "modelId"); + pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId"); + catalogVersion = optionalText(catalogVersion, "catalogVersion"); + } + + public static BudgetReservation reserved( + ReservationId id, + BudgetReservationRequest request, + Instant createdAt + ) { + Objects.requireNonNull(request, "request must not be null"); + return new BudgetReservation( + id, + request.key(), + request.limit(), + request.safeUpperBoundCost(), + request.idempotencyKey(), + request.modelId(), + request.pricingPolicyId(), + request.catalogVersion(), + ReservationState.RESERVED, + createdAt + ); + } + + public boolean matches(BudgetReservationRequest request) { + return key.equals(request.key()) + && limit.equals(request.limit()) + && amount.equals(request.safeUpperBoundCost()) + && idempotencyKey.equals(request.idempotencyKey()) + && Objects.equals(modelId, request.modelId()) + && Objects.equals(pricingPolicyId, request.pricingPolicyId()) + && Objects.equals(catalogVersion, request.catalogVersion()); + } + + private static String optionalText(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/BudgetReservationRequest.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java new file mode 100644 index 0000000..56e063b --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationRequest.java @@ -0,0 +1,80 @@ +package io.tokenpilot.budget; + +import io.tokenpilot.core.domain.Cost; + +import java.util.Objects; + +/** + * 호출 전 안전 상한 비용을 예산 bucket에 예약하기 위한 immutable 요청입니다. + * + *

{@code limit}은 bucket 생성 시 고정되는 정책 snapshot이고, + * {@code safeUpperBoundCost}는 예약할 실제 금액입니다. 모델과 가격 식별자는 + * 같은 idempotency key의 요청 payload가 바뀌었는지 검증하는 fingerprint로 사용됩니다.

+ */ +public record BudgetReservationRequest( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + IdempotencyKey idempotencyKey, + String modelId, + String pricingPolicyId, + String catalogVersion +) { + + 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"); + Objects.requireNonNull(idempotencyKey, "idempotencyKey must not be null"); + if (limit.value().signum() <= 0) { + throw new IllegalArgumentException("limit must be greater than zero"); + } + modelId = optionalText(modelId, "modelId"); + pricingPolicyId = optionalText(pricingPolicyId, "pricingPolicyId"); + catalogVersion = optionalText(catalogVersion, "catalogVersion"); + } + + public BudgetReservationRequest( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + String idempotencyKey + ) { + this( + key, + limit, + safeUpperBoundCost, + new IdempotencyKey(idempotencyKey), + null, + null, + null + ); + } + + public BudgetReservationRequest( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + String idempotencyKey, + String modelId, + String pricingPolicyId, + String catalogVersion + ) { + this( + key, + limit, + safeUpperBoundCost, + new IdempotencyKey(idempotencyKey), + modelId, + pricingPolicyId, + catalogVersion + ); + } + + private static String optionalText(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/BudgetReservationResult.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationResult.java new file mode 100644 index 0000000..2d4f485 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetReservationResult.java @@ -0,0 +1,84 @@ +package io.tokenpilot.budget; + +import java.util.Objects; + +/** + * 원자적 예산 예약 시도의 결과입니다. + */ +public record BudgetReservationResult( + ReservationStatus status, + BudgetReservation reservation, + BudgetSnapshot snapshot, + String reason +) { + + public BudgetReservationResult { + Objects.requireNonNull(status, "status must not be null"); + Objects.requireNonNull(snapshot, "snapshot must not be null"); + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason must not be blank"); + } + if ((status == ReservationStatus.CREATED || status == ReservationStatus.REUSED) + && reservation == null) { + throw new IllegalArgumentException(status + " result must include a reservation"); + } + } + + public ReservationId reservationId() { + return reservation == null ? null : reservation.id(); + } + + public boolean isAccepted() { + return status == ReservationStatus.CREATED || status == ReservationStatus.REUSED; + } + + public static BudgetReservationResult created( + BudgetReservation reservation, + BudgetSnapshot snapshot + ) { + return new BudgetReservationResult( + ReservationStatus.CREATED, + reservation, + snapshot, + "예산 예약이 생성되었습니다" + ); + } + + public static BudgetReservationResult reused( + BudgetReservation reservation, + BudgetSnapshot snapshot + ) { + return new BudgetReservationResult( + ReservationStatus.REUSED, + reservation, + snapshot, + "동일 idempotency key의 기존 예약을 재사용했습니다" + ); + } + + public static BudgetReservationResult blocked( + BudgetSnapshot snapshot, + String reason + ) { + return new BudgetReservationResult(ReservationStatus.BLOCKED, null, snapshot, reason); + } + + public static BudgetReservationResult conflict( + BudgetReservation reservation, + BudgetSnapshot snapshot, + String reason + ) { + return new BudgetReservationResult(ReservationStatus.CONFLICT, reservation, snapshot, reason); + } + + public static BudgetReservationResult currencyMismatch( + BudgetSnapshot snapshot + ) { + return new BudgetReservationResult( + ReservationStatus.CURRENCY_MISMATCH, + null, + snapshot, + "예산 통화와 예약 비용 통화가 일치하지 않습니다" + ); + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetSnapshot.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetSnapshot.java new file mode 100644 index 0000000..9897c01 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetSnapshot.java @@ -0,0 +1,66 @@ +package io.tokenpilot.budget; + +import io.tokenpilot.core.domain.Cost; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +/** + * 예산 bucket의 framework-independent 읽기 snapshot입니다. + */ +public record BudgetSnapshot( + BudgetKey key, + Cost limit, + Cost committedCost, + Cost activeReservedCost, + Cost pendingReconciliationLiability, + Set activeReservationIds +) { + + public BudgetSnapshot { + Objects.requireNonNull(key, "key must not be null"); + Objects.requireNonNull(limit, "limit must not be null"); + Objects.requireNonNull(committedCost, "committedCost must not be null"); + Objects.requireNonNull(activeReservedCost, "activeReservedCost must not be null"); + Objects.requireNonNull( + pendingReconciliationLiability, + "pendingReconciliationLiability must not be null" + ); + Objects.requireNonNull(activeReservationIds, "activeReservationIds must not be null"); + if (limit.value().signum() <= 0) { + throw new IllegalArgumentException("limit must be greater than zero"); + } + if (!limit.currency().equals(committedCost.currency()) + || !limit.currency().equals(activeReservedCost.currency()) + || !limit.currency().equals(pendingReconciliationLiability.currency())) { + throw new IllegalArgumentException("budget snapshot costs must use the same currency"); + } + activeReservationIds = Collections.unmodifiableSet(Set.copyOf(activeReservationIds)); + } + + public static BudgetSnapshot empty(BudgetKey key, Cost limit) { + Cost zero = Cost.zero(limit.currency()); + return new BudgetSnapshot(key, limit, zero, zero, zero, Set.of()); + } + + /** + * 예약과 미해결 정산 부채를 포함한 admission 기준 사용량입니다. + */ + public Cost effectiveUsage() { + return committedCost + .add(activeReservedCost) + .add(pendingReconciliationLiability); + } + + /** + * 사용량을 반영한 남은 예산입니다. 초과 상태에서는 0입니다. + */ + public Cost remaining() { + Cost effectiveUsage = effectiveUsage(); + if (effectiveUsage.compareTo(limit) >= 0) { + return Cost.zero(limit.currency()); + } + return Cost.of(limit.value().subtract(effectiveUsage.value()), limit.currency()); + } +} 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 171b1df..cfe8b14 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 @@ -7,7 +7,94 @@ */ public interface BudgetStateStore { + /** + * 기존 확정 비용 조회 API입니다. 예약 금액은 포함하지 않습니다. + * + *

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

+ */ Cost getAccumulatedCost(BudgetKey key, Cost limit); + /** + * 기존 확정 비용 누적 API입니다. 예약 lifecycle을 변경하지 않습니다. + */ void addCost(BudgetKey key, Cost limit, Cost amount); + + /** + * 조회와 안전 상한 예약을 하나의 원자적 연산으로 수행합니다. + * + *

기존 구현체와의 source 호환성을 위해 기본 구현은 지원하지 않음을 명시적으로 + * 반환합니다. 예약을 지원하는 구현체는 이 메서드 또는 요청 overload를 구현해야 합니다.

+ * + * @param key 예산 bucket 식별자 + * @param limit bucket에 고정할 예산 limit snapshot + * @param safeUpperBoundCost 예약할 보수적 비용 상한 + * @param idempotencyKey 중복 요청 식별자 + * @return 생성·재사용·차단·충돌·통화 불일치 결과 + */ + default BudgetReservationResult checkAndReserve( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + String idempotencyKey + ) { + return checkAndReserve(new BudgetReservationRequest( + key, + limit, + safeUpperBoundCost, + idempotencyKey + )); + } + + /** + * typed idempotency key를 사용하는 원자적 예약 overload입니다. + */ + default BudgetReservationResult checkAndReserve( + BudgetKey key, + Cost limit, + Cost safeUpperBoundCost, + IdempotencyKey idempotencyKey + ) { + return checkAndReserve(new BudgetReservationRequest( + key, + limit, + safeUpperBoundCost, + idempotencyKey, + null, + null, + null + )); + } + + /** + * 모델·가격 snapshot metadata를 포함한 원자적 예약 요청입니다. + */ + default BudgetReservationResult checkAndReserve(BudgetReservationRequest request) { + throw new UnsupportedOperationException( + "This BudgetStateStore does not support atomic reservations" + ); + } + + /** + * 예약과 미해결 정산 부채를 포함한 읽기 snapshot입니다. + * 기존 저장소는 확정 비용만 포함한 호환 snapshot을 반환할 수 있습니다. + */ + default BudgetSnapshot snapshot(BudgetKey key, Cost limit) { + Cost committedCost = getAccumulatedCost(key, limit); + return new BudgetSnapshot( + key, + limit, + committedCost, + Cost.zero(limit.currency()), + Cost.zero(limit.currency()), + java.util.Set.of() + ); + } + + /** + * {@link #snapshot(BudgetKey, Cost)}의 명시적인 query alias입니다. + */ + default BudgetSnapshot getSnapshot(BudgetKey key, Cost limit) { + return snapshot(key, limit); + } } diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/IdempotencyKey.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/IdempotencyKey.java new file mode 100644 index 0000000..9640c1b --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/IdempotencyKey.java @@ -0,0 +1,18 @@ +package io.tokenpilot.budget; + +/** + * 같은 요청의 중복 예약을 식별하는 불변 키입니다. + */ +public record IdempotencyKey(String value) { + + public IdempotencyKey { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("value must not be blank"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationId.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationId.java new file mode 100644 index 0000000..e22dcf6 --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationId.java @@ -0,0 +1,24 @@ +package io.tokenpilot.budget; + +import java.util.UUID; + +/** + * 예산 예약을 식별하는 불변 ID입니다. + */ +public record ReservationId(String value) { + + public ReservationId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("value must not be blank"); + } + } + + public static ReservationId random() { + return new ReservationId(UUID.randomUUID().toString()); + } + + @Override + public String toString() { + 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 new file mode 100644 index 0000000..3a1a2cf --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationState.java @@ -0,0 +1,10 @@ +package io.tokenpilot.budget; + +/** + * 예산 예약의 현재 회계 상태입니다. + * + *

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

+ */ +public enum ReservationState { + RESERVED +} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStatus.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStatus.java new file mode 100644 index 0000000..08c54de --- /dev/null +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/ReservationStatus.java @@ -0,0 +1,12 @@ +package io.tokenpilot.budget; + +/** + * 예산 예약 시도 결과입니다. + */ +public enum ReservationStatus { + CREATED, + REUSED, + BLOCKED, + CONFLICT, + CURRENCY_MISMATCH +} 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 e51e4c5..97bb3de 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,59 +1,233 @@ package io.tokenpilot.budget.internal; import io.tokenpilot.budget.BudgetKey; +import io.tokenpilot.budget.BudgetReservation; +import io.tokenpilot.budget.BudgetReservationRequest; +import io.tokenpilot.budget.BudgetReservationResult; +import io.tokenpilot.budget.BudgetSnapshot; import io.tokenpilot.budget.BudgetStateStore; +import io.tokenpilot.budget.IdempotencyKey; +import io.tokenpilot.budget.ReservationId; import io.tokenpilot.core.domain.Cost; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; /** - * resolved {@link BudgetKey}별 비용을 관리하는 인메모리 저장소입니다. + * resolved {@link BudgetKey}별 확정 비용과 원자적 예약을 관리하는 인메모리 저장소입니다. + * + *

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

*/ public class InMemoryBudgetStateStore implements BudgetStateStore { - private final ConcurrentMap store = new ConcurrentHashMap<>(); - - @Override - public Cost getAccumulatedCost(BudgetKey key, Cost limit) { - validateArguments(key, limit); - Bucket bucket = store.get(key); - if (bucket == null) { - return Cost.zero(limit.currency()); - } - bucket.validate(limit); - return bucket.accumulatedCost(); - } - - @Override - public void addCost(BudgetKey key, Cost limit, Cost amount) { - validateArguments(key, limit); - Objects.requireNonNull(amount, "amount must not be null"); - if (!limit.currency().equals(amount.currency())) { - throw new IllegalArgumentException("Budget currency does not match cost currency"); - } - - store.compute(key, (ignored, bucket) -> { - if (bucket == null) { - return new Bucket(limit, amount); - } - bucket.validate(limit); - return new Bucket(limit, bucket.accumulatedCost().add(amount)); - }); - } - - private void validateArguments(BudgetKey key, Cost limit) { - Objects.requireNonNull(key, "key must not be null"); - Objects.requireNonNull(limit, "limit must not be null"); - } - - private record Bucket(Cost limit, Cost accumulatedCost) { - - private void validate(Cost expectedLimit) { - if (!limit.equals(expectedLimit)) { - throw new IllegalArgumentException("Budget policy snapshot changed for an existing key"); - } - } - } + private final ConcurrentMap store = new ConcurrentHashMap<>(); + private final ConcurrentMap idempotencyIndex = + new ConcurrentHashMap<>(); + private final Clock clock; + private final Supplier reservationIdGenerator; + + public InMemoryBudgetStateStore() { + this(Clock.systemUTC(), ReservationId::random); + } + + public InMemoryBudgetStateStore( + Clock clock, + Supplier reservationIdGenerator + ) { + this.clock = Objects.requireNonNull(clock, "clock must not be null"); + this.reservationIdGenerator = Objects.requireNonNull( + reservationIdGenerator, + "reservationIdGenerator must not be null" + ); + } + + @Override + public Cost getAccumulatedCost(BudgetKey key, Cost limit) { + validateArguments(key, limit); + Bucket bucket = store.get(key); + if (bucket == null) { + return Cost.zero(limit.currency()); + } + synchronized (bucket) { + bucket.validate(limit); + return bucket.committedCost; + } + } + + @Override + public void addCost(BudgetKey key, Cost limit, Cost amount) { + validateArguments(key, limit); + Objects.requireNonNull(amount, "amount must not be null"); + validateCurrency(limit, amount); + + Bucket bucket = store.computeIfAbsent(key, ignored -> new Bucket(limit)); + synchronized (bucket) { + bucket.validate(limit); + bucket.committedCost = bucket.committedCost.add(amount); + } + } + + @Override + 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; + } + + if (!request.limit().currency().equals(request.safeUpperBoundCost().currency())) { + result.set(BudgetReservationResult.currencyMismatch( + BudgetSnapshot.empty(request.key(), request.limit()) + )); + return null; + } + + 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() + ); + bucket.reservationsById.put(reservationId, reservation); + result.set(BudgetReservationResult.created( + reservation, + bucket.snapshot(request.key()) + )); + return reservation; + } + }); + + return Objects.requireNonNull(result.get(), "reservation result must be set"); + } + + @Override + public BudgetSnapshot snapshot(BudgetKey key, Cost limit) { + validateArguments(key, limit); + Bucket bucket = store.get(key); + if (bucket == null) { + return BudgetSnapshot.empty(key, limit); + } + synchronized (bucket) { + bucket.validate(limit); + return bucket.snapshot(key); + } + } + + private void validateArguments(BudgetKey key, Cost limit) { + Objects.requireNonNull(key, "key must not be null"); + Objects.requireNonNull(limit, "limit must not be null"); + if (limit.value().signum() <= 0) { + throw new IllegalArgumentException("limit must be greater than zero"); + } + } + + private void validateCurrency(Cost limit, Cost amount) { + if (!limit.currency().equals(amount.currency())) { + throw new IllegalArgumentException("Budget currency does not match cost currency"); + } + } + + private static final class Bucket { + private final Cost limit; + private Cost committedCost; + private Cost activeReservedCost; + private Cost pendingReconciliationLiability; + private final Map reservationsById = + new LinkedHashMap<>(); + + private Bucket(Cost limit) { + this.limit = limit; + this.committedCost = Cost.zero(limit.currency()); + this.activeReservedCost = Cost.zero(limit.currency()); + this.pendingReconciliationLiability = Cost.zero(limit.currency()); + } + + private void validate(Cost expectedLimit) { + if (!limit.equals(expectedLimit)) { + throw new IllegalArgumentException( + "Budget policy snapshot changed for an existing key" + ); + } + } + + private Cost effectiveUsage() { + return committedCost + .add(activeReservedCost) + .add(pendingReconciliationLiability); + } + + private BudgetSnapshot snapshot(BudgetKey key) { + return new BudgetSnapshot( + key, + limit, + committedCost, + activeReservedCost, + pendingReconciliationLiability, + reservationsById.keySet() + ); + } + } } 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 996b590..438df18 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,8 +3,10 @@ import io.tokenpilot.budget.BudgetEvaluator; import io.tokenpilot.budget.BudgetPolicy; import io.tokenpilot.budget.BudgetStateStore; +import io.tokenpilot.budget.ReservationId; import java.time.Clock; +import java.util.function.Supplier; /** * 예산 제어 컴포넌트 생성을 위한 팩토리 클래스입니다. @@ -18,6 +20,13 @@ public static BudgetStateStore inMemoryBudgetStateStore() { return new InMemoryBudgetStateStore(); } + public static BudgetStateStore inMemoryBudgetStateStore( + Clock clock, + Supplier reservationIdGenerator + ) { + return new InMemoryBudgetStateStore(clock, reservationIdGenerator); + } + public static BudgetEvaluator defaultBudgetEvaluator( BudgetStateStore store, BudgetPolicy policy, 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 new file mode 100644 index 0000000..7b74714 --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/BudgetReservationStoreTest.java @@ -0,0 +1,322 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.budget.BudgetKey; +import io.tokenpilot.budget.BudgetReservationRequest; +import io.tokenpilot.budget.BudgetReservationResult; +import io.tokenpilot.budget.BudgetSnapshot; +import io.tokenpilot.budget.BudgetWindow; +import io.tokenpilot.budget.IdempotencyKey; +import io.tokenpilot.budget.ReservationId; +import io.tokenpilot.budget.ReservationStatus; +import io.tokenpilot.budget.ReservationState; +import io.tokenpilot.core.domain.Cost; +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.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class BudgetReservationStoreTest { + + 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 + void safe_upper_bound를_원자적으로_예약하고_예약량을_snapshot에_반영한다() { + InMemoryBudgetStateStore store = store(); + + BudgetReservationResult result = store.checkAndReserve( + KEY, + LIMIT, + usd("60.00"), + "request-1" + ); + + assertThat(result.status()).isEqualTo(ReservationStatus.CREATED); + assertThat(result.reservation()).isNotNull(); + assertThat(result.reservation().state()).isEqualTo(ReservationState.RESERVED); + assertThat(result.reservation().amount()).isEqualTo(usd("60.00")); + assertThat(result.reservation().createdAt()).isEqualTo(CLOCK.instant()); + assertThat(result.snapshot().activeReservedCost()).isEqualTo(usd("60.00")); + assertThat(result.snapshot().effectiveUsage()).isEqualTo(usd("60.00")); + assertThat(result.snapshot().remaining()).isEqualTo(usd("40.00")); + assertThat(result.snapshot().activeReservationIds()) + .containsExactly(result.reservation().id()); + } + + @Test + void safe_upper_bound가_한도에_도달하면_예약하지_않고_BLOCKED를_반환한다() { + InMemoryBudgetStateStore store = store(); + + BudgetReservationResult result = store.checkAndReserve( + KEY, + LIMIT, + usd("100.00"), + "request-1" + ); + + assertThat(result.status()).isEqualTo(ReservationStatus.BLOCKED); + assertThat(result.reservation()).isNull(); + assertThat(result.snapshot().effectiveUsage()).isEqualTo(Cost.zero(USD)); + assertThat(result.snapshot().activeReservationIds()).isEmpty(); + assertThat(store.snapshot(KEY, LIMIT).effectiveUsage()).isEqualTo(Cost.zero(USD)); + } + + @Test + void 이미_예약된_금액까지_포함해_다음_예약을_BLOCKED한다() { + InMemoryBudgetStateStore store = store(); + + BudgetReservationResult first = store.checkAndReserve( + KEY, + LIMIT, + usd("60.00"), + "request-1" + ); + BudgetReservationResult second = store.checkAndReserve( + KEY, + LIMIT, + usd("40.00"), + "request-2" + ); + + assertThat(first.status()).isEqualTo(ReservationStatus.CREATED); + assertThat(second.status()).isEqualTo(ReservationStatus.BLOCKED); + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()).isEqualTo(usd("60.00")); + } + + @Test + void 같은_idempotency_key_재호출은_기존_예약을_REUSED한다() { + InMemoryBudgetStateStore store = store(); + + BudgetReservationResult created = store.checkAndReserve( + request("request-1", "gpt-4o-mini", "pricing-v1", "catalog-v1", "60.00") + ); + BudgetReservationResult reused = store.checkAndReserve( + request("request-1", "gpt-4o-mini", "pricing-v1", "catalog-v1", "60.00") + ); + + assertThat(created.status()).isEqualTo(ReservationStatus.CREATED); + assertThat(reused.status()).isEqualTo(ReservationStatus.REUSED); + assertThat(reused.reservation().id()).isEqualTo(created.reservation().id()); + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()).isEqualTo(usd("60.00")); + assertThat(store.snapshot(KEY, LIMIT).activeReservationIds()).hasSize(1); + } + + @Test + void 같은_idempotency_key의_다른_요청은_CONFLICT이고_기존_예약을_보존한다() { + InMemoryBudgetStateStore store = store(); + + BudgetReservationResult created = store.checkAndReserve( + request("request-1", "gpt-4o-mini", "pricing-v1", "catalog-v1", "60.00") + ); + BudgetReservationResult conflict = store.checkAndReserve( + request("request-1", "gpt-4o", "pricing-v2", "catalog-v2", "70.00") + ); + + assertThat(conflict.status()).isEqualTo(ReservationStatus.CONFLICT); + assertThat(conflict.reservation().id()).isEqualTo(created.reservation().id()); + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()).isEqualTo(usd("60.00")); + assertThat(store.snapshot(KEY, LIMIT).activeReservationIds()) + .containsExactly(created.reservation().id()); + } + + @Test + void 같은_idempotency_key를_다른_window에서_사용하면_CONFLICT다() { + InMemoryBudgetStateStore store = store(); + + BudgetReservationResult created = store.checkAndReserve( + request("request-1", "gpt-4o-mini", "pricing-v1", "catalog-v1", "10.00") + ); + BudgetReservationResult conflict = store.checkAndReserve( + new BudgetReservationRequest( + new BudgetKey("budget-policy", "tenant", "tenant-a", BudgetWindow.parse("2026-09")), + LIMIT, + usd("10.00"), + new IdempotencyKey("request-1"), + "gpt-4o-mini", + "pricing-v1", + "catalog-v1" + ) + ); + + assertThat(conflict.status()).isEqualTo(ReservationStatus.CONFLICT); + assertThat(conflict.reservation().id()).isEqualTo(created.reservation().id()); + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()).isEqualTo(usd("10.00")); + assertThat(store.snapshot( + new BudgetKey("budget-policy", "tenant", "tenant-a", BudgetWindow.parse("2026-09")), + LIMIT + ).effectiveUsage()).isEqualTo(Cost.zero(USD)); + } + + @Test + void 통화가_다르면_CURRENCY_MISMATCH이며_상태를_변경하지_않는다() { + InMemoryBudgetStateStore store = store(); + Currency krw = Currency.getInstance("KRW"); + + BudgetReservationResult result = store.checkAndReserve( + KEY, + LIMIT, + Cost.of(new BigDecimal("60.00"), krw), + "request-1" + ); + + assertThat(result.status()).isEqualTo(ReservationStatus.CURRENCY_MISMATCH); + assertThat(result.reservation()).isNull(); + assertThat(result.snapshot().effectiveUsage()).isEqualTo(Cost.zero(USD)); + assertThat(store.snapshot(KEY, LIMIT).effectiveUsage()).isEqualTo(Cost.zero(USD)); + } + + @Test + void 기존_bucket과_다른_통화의_예약은_CURRENCY_MISMATCH이며_기존상태를_보존한다() { + InMemoryBudgetStateStore store = store(); + Currency krw = Currency.getInstance("KRW"); + store.addCost(KEY, LIMIT, usd("10.00")); + + BudgetReservationResult result = store.checkAndReserve( + KEY, + Cost.of(new BigDecimal("1000.00"), krw), + Cost.of(new BigDecimal("100.00"), krw), + "request-1" + ); + + assertThat(result.status()).isEqualTo(ReservationStatus.CURRENCY_MISMATCH); + assertThat(store.snapshot(KEY, LIMIT).effectiveUsage()).isEqualTo(usd("10.00")); + } + + @Test + void 기존_idempotency_key에_다른_통화를_사용하면_CONFLICT이고_기존예약을_보존한다() { + InMemoryBudgetStateStore store = store(); + Currency krw = Currency.getInstance("KRW"); + BudgetReservationResult created = store.checkAndReserve( + KEY, + LIMIT, + usd("60.00"), + "request-1" + ); + + BudgetReservationResult conflict = store.checkAndReserve( + KEY, + LIMIT, + Cost.of(new BigDecimal("60.00"), krw), + "request-1" + ); + + assertThat(conflict.status()).isEqualTo(ReservationStatus.CONFLICT); + assertThat(conflict.reservationId()).isEqualTo(created.reservationId()); + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()).isEqualTo(usd("60.00")); + } + + @Test + void 서로_다른_bucket의_동시_예약도_각각_격리된다() throws Exception { + InMemoryBudgetStateStore store = store(); + BudgetKey otherKey = new BudgetKey( + "budget-policy", + "tenant", + "tenant-b", + BudgetWindow.parse("2026-08") + ); + + BudgetReservationResult tenantA = store.checkAndReserve(KEY, LIMIT, usd("60.00"), "request-a"); + BudgetReservationResult tenantB = store.checkAndReserve(otherKey, LIMIT, usd("60.00"), "request-b"); + + assertThat(tenantA.status()).isEqualTo(ReservationStatus.CREATED); + assertThat(tenantB.status()).isEqualTo(ReservationStatus.CREATED); + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()).isEqualTo(usd("60.00")); + assertThat(store.snapshot(otherKey, LIMIT).activeReservedCost()).isEqualTo(usd("60.00")); + } + + @Test + void 같은_bucket의_동시_예약은_한도보다_많이_예약하지_않는다() throws Exception { + InMemoryBudgetStateStore store = store(); + CountDownLatch start = new CountDownLatch(1); + + try (var executor = Executors.newFixedThreadPool(2)) { + Future first = executor.submit(() -> { + start.await(); + return store.checkAndReserve(KEY, LIMIT, usd("60.00"), "request-1"); + }); + Future second = executor.submit(() -> { + start.await(); + return store.checkAndReserve(KEY, LIMIT, usd("60.00"), "request-2"); + }); + start.countDown(); + + Set statuses = Set.of( + first.get(5, TimeUnit.SECONDS).status(), + second.get(5, TimeUnit.SECONDS).status() + ); + assertThat(statuses).containsExactlyInAnyOrder( + ReservationStatus.CREATED, + ReservationStatus.BLOCKED + ); + assertThat(store.snapshot(KEY, LIMIT).activeReservedCost()).isEqualTo(usd("60.00")); + executor.shutdown(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + @Test + void snapshot은_legacy_누적비용과_예약비용을_분리해_보여준다() { + InMemoryBudgetStateStore store = store(); + store.addCost(KEY, LIMIT, usd("10.00")); + store.checkAndReserve(KEY, LIMIT, usd("20.00"), "request-1"); + + BudgetSnapshot snapshot = store.snapshot(KEY, LIMIT); + + assertThat(snapshot.committedCost()).isEqualTo(usd("10.00")); + assertThat(snapshot.activeReservedCost()).isEqualTo(usd("20.00")); + assertThat(snapshot.pendingReconciliationLiability()).isEqualTo(Cost.zero(USD)); + assertThat(snapshot.effectiveUsage()).isEqualTo(usd("30.00")); + assertThat(snapshot.remaining()).isEqualTo(usd("70.00")); + } + + private static InMemoryBudgetStateStore store() { + AtomicInteger sequence = new AtomicInteger(); + return new InMemoryBudgetStateStore( + CLOCK, + () -> new ReservationId("reservation-" + sequence.incrementAndGet()) + ); + } + + private static BudgetReservationRequest request( + String idempotencyKey, + String modelId, + String pricingPolicyId, + String catalogVersion, + String amount + ) { + return new BudgetReservationRequest( + KEY, + LIMIT, + usd(amount), + new IdempotencyKey(idempotencyKey), + modelId, + pricingPolicyId, + catalogVersion + ); + } + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } +}