diff --git a/README.md b/README.md index 7937560..0cf2085 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Spring AI 애플리케이션에서 AI 호출 토큰 사용량과 비용을 기 - 모델별 가격 정책: `PricingPlan` - 토큰 타입별 사용량: `PROMPT`, `COMPLETION`, `REASONING`, `CACHED_PROMPT`, `CACHED_COMPLETION` -- 비용 계산: 1K 토큰당 단가 기반, 최종 비용 소수점 6자리 반올림 +- 비용 계산: 1K 토큰당 단가 기반으로 내부 정밀도를 보존하고, 외부 경계에서 `RoundingPolicy`로 명시적으로 반올림 - 비용 기록 이벤트: `CostRecordedEvent` - 리스너 기반 확장: `LedgerListener` - Spring AI 응답 usage 추출: `UsageExtractor` diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java index 9b35981..2a4c858 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetDecision.java @@ -1,18 +1,17 @@ package io.tokenledger.budget; -import java.math.BigDecimal; +import io.tokenledger.core.domain.Cost; /** * 예산 평가 결과를 나타내는 값 객체입니다. *

- * 호출 가능 여부와 - * 판단에 필요한 최소한의 정보를 담습니다. + * 호출 가능 여부와 판단에 필요한 최소한의 정보를 담습니다. */ public record BudgetDecision( - BudgetState state, - String reason, - BigDecimal currentUsage, - BigDecimal limit + BudgetState state, + String reason, + Cost currentUsage, + Cost limit ) {} diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java index 3de85cc..812c77e 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetEvaluator.java @@ -1,28 +1,26 @@ package io.tokenledger.budget; -import java.math.BigDecimal; +import io.tokenledger.core.domain.Cost; import java.util.Map; /** * AI 호출 전 예산 초과 여부를 판단하는 인터페이스입니다. *

- * 구현체는 현재까지 누적된 비용과 - * 이번 호출로 발생할 비용을 기준으로 - * 호출을 허용하거나 차단하는 역할을 합니다. + * 구현체는 현재까지 누적된 비용과 이번 호출로 발생할 비용을 기준으로 호출을 허용하거나 차단하는 역할을 합니다. */ public interface BudgetEvaluator { - /** - * 단순히 현재의 누적 비용이 예산 한도를 초과했는지만 판단합니다. (부수 효과 없음) - */ - BudgetDecision evaluate(Map tags); + /** + * 단순히 현재의 누적 비용이 예산 한도를 초과했는지만 판단합니다. (부수 효과 없음) + */ + BudgetDecision evaluate(Map tags); - /** - * 이번 호출로 발생할 예상 비용을 포함하여 예산 초과 여부를 판단합니다. (부수 효과 없음) - */ - BudgetDecision evaluate( - Map tags, - BigDecimal costAmount - ); + /** + * 이번 호출로 발생할 예상 비용을 포함하여 예산 초과 여부를 판단합니다. (부수 효과 없음) + */ + BudgetDecision evaluate( + Map tags, + Cost cost + ); } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetState.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetState.java index 5f62417..9cea19a 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetState.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetState.java @@ -10,5 +10,6 @@ public enum BudgetState { ALLOW, WARN, - BLOCK + BLOCK, + CURRENCY_MISMATCH } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java index 31c1d3b..c3c2d5a 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java @@ -1,6 +1,7 @@ package io.tokenledger.budget; -import java.math.BigDecimal; +import io.tokenledger.core.domain.Cost; +import java.util.Currency; import java.util.Map; @@ -13,7 +14,7 @@ public interface BudgetStateStore { - BigDecimal getAccumulatedCost(Map tags); + Cost getAccumulatedCost(Map tags, Currency currency); - void addCost(Map tags, BigDecimal amount); + void addCost(Map tags, Cost cost); } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java index c19e0e6..fb925a0 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/DefaultBudgetEvaluator.java @@ -3,89 +3,112 @@ import io.tokenledger.budget.*; import io.tokenledger.budget.exception.BudgetExceededException; +import io.tokenledger.core.domain.Cost; import java.math.BigDecimal; import java.util.Map; /** * BudgetEvaluator의 기본 구현체입니다. - * + *

* 판단 기준: * - 80% 미만 → ALLOW * - 80% 이상 → WARN * - 100% 이상 → BLOCK (예외 발생) - * + * - 통화 불일치 → CURRENCY_MISMATCH + *

* 이 클래스의 evaluate 메서드는 부수 효과가 없는 순수 함수로 동작합니다. * 실제 비용 누적은 BudgetStateStore.addCost를 통해 별도로 수행해야 합니다. */ public class DefaultBudgetEvaluator implements BudgetEvaluator { - private final BudgetStateStore store; - private final BigDecimal monthlyLimit; + private static final BigDecimal WARN_THRESHOLD_RATE = new BigDecimal("0.8"); - public DefaultBudgetEvaluator( - BudgetStateStore store, - BigDecimal monthlyLimit - ) { - this.store = store; - this.monthlyLimit = monthlyLimit; - } + private final BudgetStateStore store; + private final Cost monthlyLimit; - @Override - public BudgetDecision evaluate(Map tags) { - return evaluate(tags, BigDecimal.ZERO); - } + public DefaultBudgetEvaluator( + BudgetStateStore store, + Cost monthlyLimit + ) { + this.store = store; + this.monthlyLimit = monthlyLimit; + } + + @Override + public BudgetDecision evaluate(Map tags) { + return evaluate(tags, Cost.zero(monthlyLimit .currency())); + } - @Override - public BudgetDecision evaluate( - Map tags, - BigDecimal costAmount - ) { + @Override + public BudgetDecision evaluate( + Map tags, + Cost cost + ) { - // ✅ 현재까지 누적 비용 - BigDecimal accumulated = store.getAccumulatedCost(tags); + // ✅ 현재까지 누적 비용 + Cost accumulated = store.getAccumulatedCost(tags, monthlyLimit.currency()); - // ✅ 이번 호출까지 포함한 비용 (비교용) - BigDecimal nextUsage = accumulated.add(costAmount); + if (hasCurrencyMismatch(accumulated) || hasCurrencyMismatch(cost)) { + return currencyMismatchDecision(accumulated); + } - // ✅ 경고 기준 (80%) - BigDecimal warnThreshold = - monthlyLimit.multiply(new BigDecimal("0.8")); + // ✅ 이번 호출까지 포함한 비용 (비교용) + Cost nextUsage = accumulated.add(cost); + + // ✅ 경고 기준 (80%) + Cost warnThreshold = Cost.of( + monthlyLimit.amount().multiply(WARN_THRESHOLD_RATE), + monthlyLimit.currency() + ); /* ===================== 1️⃣ 차단 (BLOCK) ===================== */ - if (nextUsage.compareTo(monthlyLimit) >= 0) { + if (nextUsage.compareTo(monthlyLimit) >= 0) { - BudgetDecision decision = new BudgetDecision( - BudgetState.BLOCK, - "월 예산 초과로 AI 호출이 차단되었습니다", - nextUsage, - monthlyLimit - ); + BudgetDecision decision = new BudgetDecision( + BudgetState.BLOCK, + "월 예산 초과로 AI 호출이 차단되었습니다", + nextUsage, + monthlyLimit + ); - throw new BudgetExceededException(decision); - } + throw new BudgetExceededException(decision); + } /* ===================== 2️⃣ 경고 (WARN) ===================== */ - if (nextUsage.compareTo(warnThreshold) >= 0) { - return new BudgetDecision( - BudgetState.WARN, - "월 예산의 80%에 도달했습니다", - nextUsage, - monthlyLimit - ); - } + if (nextUsage.compareTo(warnThreshold) >= 0) { + return new BudgetDecision( + BudgetState.WARN, + "월 예산의 80%에 도달했습니다", + nextUsage, + monthlyLimit + ); + } /* ===================== 3️⃣ 허용 (ALLOW) ===================== */ - return new BudgetDecision( - BudgetState.ALLOW, - "예산 범위 내입니다", - nextUsage, - monthlyLimit - ); - } + return new BudgetDecision( + BudgetState.ALLOW, + "예산 범위 내입니다", + nextUsage, + monthlyLimit + ); + } + + private boolean hasCurrencyMismatch(Cost cost) { + return !monthlyLimit.currency().equals(cost.currency()); + } + + private BudgetDecision currencyMismatchDecision(Cost currentUsage) { + return new BudgetDecision( + BudgetState.CURRENCY_MISMATCH, + "예산 통화와 비용 통화가 일치하지 않습니다", + currentUsage, + monthlyLimit + ); + } } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java index 89d323a..7c394d4 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java @@ -2,35 +2,36 @@ import io.tokenledger.budget.BudgetStateStore; +import io.tokenledger.core.domain.Cost; import java.math.BigDecimal; +import java.util.Currency; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * BudgetStateStore의 인메모리 기반 구현체입니다. - * - * 예산 사용량을 메모리 내에서 누적 관리하며, - * 테스트 및 간단한 실행 환경을 위한 구현입니다. + *

+ * 예산 사용량을 메모리 내에서 누적 관리하며, 테스트 및 간단한 실행 환경을 위한 구현입니다. */ public class InMemoryBudgetStateStore implements BudgetStateStore { - private final Map store = new ConcurrentHashMap<>(); + private final Map store = new ConcurrentHashMap<>(); - private String key(Map tags) { - return tags.getOrDefault("tenant_id", "default"); - } + private String key(Map tags) { + return tags.getOrDefault("tenant_id", "default"); + } - @Override - public BigDecimal getAccumulatedCost(Map tags) { // 아직 사용 기록이 없으면 0원 - return store.getOrDefault(key(tags), BigDecimal.ZERO); - } + @Override + public Cost getAccumulatedCost(Map tags, Currency currency) { + return store.getOrDefault(key(tags), Cost.zero(currency)); + } - @Override - public void addCost(Map tags, BigDecimal amount) { // 기존 값에 amount를 더함 - store.merge(key(tags), amount, BigDecimal::add); - } + @Override + public void addCost(Map tags, Cost cost) { + store.merge(key(tags), cost, Cost::add); + } } diff --git a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/LedgerBudgetComponents.java b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/LedgerBudgetComponents.java index bda55b7..8e9c9c1 100644 --- a/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/LedgerBudgetComponents.java +++ b/token-ledger-budget/src/main/java/io/tokenledger/budget/internal/LedgerBudgetComponents.java @@ -2,6 +2,7 @@ import io.tokenledger.budget.BudgetEvaluator; import io.tokenledger.budget.BudgetStateStore; +import io.tokenledger.core.domain.Cost; /** * 예산 제어 컴포넌트 생성을 위한 팩토리 클래스입니다. @@ -15,7 +16,7 @@ public static BudgetStateStore inMemoryBudgetStateStore() { return new InMemoryBudgetStateStore(); } - public static BudgetEvaluator defaultBudgetEvaluator(BudgetStateStore store, java.math.BigDecimal monthlyLimit) { + public static BudgetEvaluator defaultBudgetEvaluator(BudgetStateStore store, Cost monthlyLimit) { return new DefaultBudgetEvaluator(store, monthlyLimit); } } diff --git a/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java b/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java index 1bcec07..6a0febc 100644 --- a/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java +++ b/token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java @@ -2,6 +2,8 @@ import io.tokenledger.budget.*; import io.tokenledger.budget.exception.BudgetExceededException; +import io.tokenledger.core.domain.Cost; +import java.util.Currency; import org.junit.jupiter.api.Test; import java.math.BigDecimal; @@ -11,90 +13,124 @@ class DefaultBudgetEvaluatorTest { - @Test - void should_return_allow_when_usage_is_below_80_percent() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - - // when - BudgetDecision decision = - evaluator.evaluate(tags, BigDecimal.valueOf(50)); - - // then - assertEquals(BudgetState.ALLOW, decision.state()); - } - - @Test - void should_return_warn_when_usage_exceeds_80_percent() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - - // when - BudgetDecision decision = - evaluator.evaluate(tags, BigDecimal.valueOf(85)); - - // then - assertEquals(BudgetState.WARN, decision.state()); - } - - @Test - void should_throw_exception_when_usage_exceeds_limit() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - - // when & then - assertThrows( - BudgetExceededException.class, - () -> evaluator.evaluate(tags, BigDecimal.valueOf(120)) - ); - } - - @Test - void should_evaluate_current_status_without_cost_amount() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - store.addCost(tags, BigDecimal.valueOf(90)); - - // when - BudgetDecision decision = evaluator.evaluate(tags); - - // then - assertEquals(BudgetState.WARN, decision.state()); - assertEquals(BigDecimal.valueOf(90), decision.currentUsage()); - } - - @Test - void evaluate_should_be_pure_function() { - // given - BudgetStateStore store = new InMemoryBudgetStateStore(); - BudgetEvaluator evaluator = - new DefaultBudgetEvaluator(store, BigDecimal.valueOf(100)); - - Map tags = Map.of("tenant_id", "test"); - - // when - evaluator.evaluate(tags, BigDecimal.valueOf(50)); - evaluator.evaluate(tags, BigDecimal.valueOf(50)); - - // then - // Still ALLOW because addCost was not called - assertEquals(BigDecimal.ZERO, store.getAccumulatedCost(tags)); - assertEquals(BudgetState.ALLOW, evaluator.evaluate(tags, BigDecimal.valueOf(50)).state()); - } + private static final Currency USD = Currency.getInstance("USD"); + private static final Currency KRW = Currency.getInstance("KRW"); + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } + + private static Cost krw(String amount) { + return Cost.of(new BigDecimal(amount), KRW); + } + + @Test + void shouldReturnAllowWhenUsageIsBelow80Percent() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + + // when + BudgetDecision decision = + evaluator.evaluate(tags, usd("50")); + + // then + assertEquals(BudgetState.ALLOW, decision.state()); + assertEquals(usd("50"), decision.currentUsage()); + assertEquals(usd("100"), decision.limit()); + } + + @Test + void shouldReturnWarnWhenUsageExceeds80Percent() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + + // when + BudgetDecision decision = + evaluator.evaluate(tags, usd("85")); + + // then + assertEquals(BudgetState.WARN, decision.state()); + assertEquals(usd("85"), decision.currentUsage()); + assertEquals(usd("100"), decision.limit()); + } + + @Test + void shouldThrowExceptionWhenUsageExceedsLimit() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + + // when & then + assertThrows( + BudgetExceededException.class, + () -> evaluator.evaluate(tags, usd("120")) + ); + } + + @Test + void shouldEvaluateCurrentStatusWithoutCostAmount() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + store.addCost(tags, usd("90")); + + // when + BudgetDecision decision = evaluator.evaluate(tags); + + // then + assertEquals(BudgetState.WARN, decision.state()); + assertEquals(usd("90"), decision.currentUsage()); + assertEquals(usd("100"), decision.limit()); + } + + @Test + void evaluateShouldBePureFunction() { + // given + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + + // when + evaluator.evaluate(tags, usd("50")); + evaluator.evaluate(tags, usd("50")); + + // then + // Still ALLOW because addCost was not called + assertEquals(Cost.zero(USD), store.getAccumulatedCost(tags, USD)); + assertEquals(BudgetState.ALLOW, evaluator.evaluate(tags, usd("50")).state()); + } + + @Test + void shouldReturnCurrencyMismatchWithoutChangingAccumulatedCost() { + BudgetStateStore store = new InMemoryBudgetStateStore(); + BudgetEvaluator evaluator = + new DefaultBudgetEvaluator(store, usd("100")); + + Map tags = Map.of("tenant_id", "test"); + store.addCost(tags, usd("10")); + Cost accumulatedBefore = store.getAccumulatedCost(tags, USD); + + BudgetDecision decision = evaluator.evaluate(tags, krw("50")); + + assertEquals(BudgetState.CURRENCY_MISMATCH, decision.state()); + assertEquals(accumulatedBefore, decision.currentUsage()); + assertEquals(usd("100"), decision.limit()); + assertEquals(accumulatedBefore, store.getAccumulatedCost(tags, USD)); + } } diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/RoundingPolicy.java b/token-ledger-core/src/main/java/io/tokenledger/core/RoundingPolicy.java new file mode 100644 index 0000000..cfa6ccd --- /dev/null +++ b/token-ledger-core/src/main/java/io/tokenledger/core/RoundingPolicy.java @@ -0,0 +1,25 @@ +package io.tokenledger.core; + +import io.tokenledger.core.domain.Cost; +import java.math.RoundingMode; + +public final class RoundingPolicy { + + public static final RoundingPolicy COST_BOUNDARY_ROUNDING = + new RoundingPolicy(6, RoundingMode.HALF_UP); + + private final int scale; + private final RoundingMode roundingMode; + + private RoundingPolicy(int scale, RoundingMode roundingMode) { + this.scale = scale; + this.roundingMode = roundingMode; + } + + public Cost apply(Cost cost) { + return Cost.of( + cost.amount().setScale(scale, roundingMode), + cost.currency() + ); + } +} \ No newline at end of file diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java index 51088c0..34bf65d 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java @@ -3,35 +3,77 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.Currency; +import java.util.Objects; /** * 계산된 AI 호출 비용 정보. * - * @param value 비용 (BigDecimal, 소수점 6자리 권장) - * @param currency 통화 (기본값: USD) + * @param amount 비용 (BigDecimal) + * @param currency 통화 (Currency) */ public record Cost( - BigDecimal value, + BigDecimal amount, Currency currency ) { - public static final Currency DEFAULT_CURRENCY = Currency.getInstance("USD"); public Cost { - value = value.setScale(6, RoundingMode.HALF_UP); + Objects.requireNonNull(amount, "amount cant be null"); + Objects.requireNonNull(currency, "currency cant be null"); + + validateNonNegativeAmount(amount); } - public static Cost of(BigDecimal value) { - return new Cost(value, DEFAULT_CURRENCY); + private static void validateNonNegativeAmount(BigDecimal amount) { + if (amount.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("Cost value must not be negative"); + } } - public static Cost zero() { - return of(BigDecimal.ZERO); + public static Cost of(BigDecimal value, Currency currency) { + return new Cost(value, currency); + } + + public static Cost zero(Currency currency) { + return of(BigDecimal.ZERO, currency); } public Cost add(Cost other) { - if (!this.currency.equals(other.currency)) { - throw new IllegalArgumentException("Cannot add costs with different currencies"); + Objects.requireNonNull(other, "cost cant be null"); + validateSameCurrency(other); + + BigDecimal totalAmount = amount.add(other.amount); + + return new Cost(totalAmount, currency); + } + + public int compareTo(Cost other) { + Objects.requireNonNull(other, "cost cant be null"); + validateSameCurrency(other); + + return amount.compareTo(other.amount); + } + + private void validateSameCurrency(Cost other) { + if (!currency.equals(other.currency)) { + throw new IllegalArgumentException("Cannot operate on costs with different currencies"); + } + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; } - return new Cost(this.value.add(other.value), this.currency); + if (!(object instanceof Cost other)) { + return false; + } + + return amount.compareTo(other.amount) == 0 + && currency.equals(other.currency); + } + + @Override + public int hashCode() { + return Objects.hash(amount.stripTrailingZeros(), currency); } -} +} \ No newline at end of file diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/domain/PricingPlan.java b/token-ledger-core/src/main/java/io/tokenledger/core/domain/PricingPlan.java index cfe3b8c..ec5cd05 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/domain/PricingPlan.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/domain/PricingPlan.java @@ -19,10 +19,12 @@ public record PricingPlan( Map rates, Currency currency ) { + static String DEFAULT_CURRENCY = "USD"; + public PricingPlan { rates = Collections.unmodifiableMap(new EnumMap<>(rates)); if (currency == null) { - currency = Currency.getInstance("USD"); + currency = Currency.getInstance(DEFAULT_CURRENCY); } // 모든 단가는 0 이상이어야 함 rates.values().forEach(v -> { diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultCostCalculator.java b/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultCostCalculator.java index 318e566..8db7d32 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultCostCalculator.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultCostCalculator.java @@ -7,31 +7,29 @@ import io.tokenledger.core.domain.TokenUsage; import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.Map; /** - * 기본 비용 계산기 구현체. - * 각 {@link TokenType} 별 단가를 적용하여 정밀하게 계산합니다. - * 1K 토큰당 가격 정보를 사용하여 소수점 10자리까지 중간 계산 후 6자리로 최종 반올림합니다. + * 기본 비용 계산기 구현체. 각 {@link TokenType} 별 단가를 적용하여 정밀하게 계산합니다. */ class DefaultCostCalculator implements CostCalculator { - private static final BigDecimal THOUSAND = BigDecimal.valueOf(1000); @Override public Cost calculate(TokenUsage usage, PricingPlan plan) { BigDecimal totalCostValue = BigDecimal.ZERO; // 사용된 모든 토큰 타입에 대해 각각의 단가를 적용하여 합산 - for (Map.Entry entry : usage.tokenCounts().entrySet()) { + for (Map.Entry entry : usage.tokenCounts() + .entrySet()) { TokenType type = entry.getKey(); Long count = entry.getValue(); if (count > 0) { BigDecimal rate = plan.getRate(type); - BigDecimal typeCost = rate.multiply(BigDecimal.valueOf(count)) - .divide(THOUSAND, 10, RoundingMode.HALF_UP); + BigDecimal typeCost = rate + .multiply(BigDecimal.valueOf(count)) + .movePointLeft(3); totalCostValue = totalCostValue.add(typeCost); } } diff --git a/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultLedgerManager.java b/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultLedgerManager.java index 1a3db63..d6a53fb 100644 --- a/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultLedgerManager.java +++ b/token-ledger-core/src/main/java/io/tokenledger/core/internal/DefaultLedgerManager.java @@ -3,6 +3,7 @@ import io.tokenledger.core.*; import io.tokenledger.core.domain.*; +import java.util.Currency; import java.util.List; import java.util.Map; import java.util.Optional; @@ -17,6 +18,8 @@ class DefaultLedgerManager implements LedgerManager { private final CostCalculator costCalculator; private final List listeners = new CopyOnWriteArrayList<>(); + static String DEFAULT_CURRENCY = "USD"; + public DefaultLedgerManager(PricingRegistry pricingRegistry, CostCalculator costCalculator) { this(pricingRegistry, costCalculator, List.of()); } @@ -44,7 +47,7 @@ public Cost record(String modelId, TokenUsage usage, Map tags) { Cost cost = planOpt .map(plan -> costCalculator.calculate(usage, plan)) - .orElse(Cost.zero()); + .orElse(Cost.zero(Currency.getInstance(DEFAULT_CURRENCY))); // 이벤트 발행 (리스너들에게 전파) if (!listeners.isEmpty()) { diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java new file mode 100644 index 0000000..46c83a3 --- /dev/null +++ b/token-ledger-core/src/test/java/io/tokenledger/core/RoundingPolicyTest.java @@ -0,0 +1,57 @@ +package io.tokenledger.core; + +import io.tokenledger.core.domain.Cost; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Currency; + +import static org.assertj.core.api.Assertions.assertThat; + +class RoundingPolicyTest { + + private static final Currency USD = Currency.getInstance("USD"); + + @Test + @DisplayName("비용 경계 반올림은 scale 6, HALF_UP을 적용한다") + void shouldRoundCostWithScaleSixAndHalfUp() { + Cost cost = Cost.of(new BigDecimal("0.0000005"), USD); + + Cost roundedCost = RoundingPolicy.COST_BOUNDARY_ROUNDING.apply(cost); + + assertThat(roundedCost.amount()).isEqualByComparingTo("0.000001"); + assertThat(roundedCost.amount().scale()).isEqualTo(6); + assertThat(roundedCost.currency()).isEqualTo(USD); + } + + @Test + @DisplayName("비용 경계 반올림은 통화를 보존한다") + void shouldPreserveCurrencyWhenRoundingCost() { + Currency krw = Currency.getInstance("KRW"); + Cost cost = Cost.of(new BigDecimal("12.3456789"), krw); + + Cost roundedCost = RoundingPolicy.COST_BOUNDARY_ROUNDING.apply(cost); + + assertThat(roundedCost.amount()).isEqualByComparingTo("12.345679"); + assertThat(roundedCost.currency()).isEqualTo(krw); + } + + @Test + @DisplayName("작은 비용을 여러 번 더한 값은 최종 한 번 반올림한 값과 같아야 한다") + void shouldRoundAccumulatedSmallCostsOnceAtBoundary() { + Cost unitCost = Cost.of(new BigDecimal("0.0000004"), USD); + Cost accumulated = Cost.zero(USD); + + for (int i = 0; i < 1000; i++) { + accumulated = accumulated.add(unitCost); + } + + Cost rounded = RoundingPolicy.COST_BOUNDARY_ROUNDING.apply(accumulated); + + assertThat(accumulated.amount()).isEqualByComparingTo("0.0004000"); + assertThat(rounded.amount()).isEqualByComparingTo("0.000400"); + assertThat(rounded.amount().scale()).isEqualTo(6); + assertThat(rounded.currency()).isEqualTo(USD); + } +} \ No newline at end of file diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java new file mode 100644 index 0000000..0596dcf --- /dev/null +++ b/token-ledger-core/src/test/java/io/tokenledger/core/domain/CostTest.java @@ -0,0 +1,136 @@ +package io.tokenledger.core.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNullPointerException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.Currency; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +public class CostTest { + + private static final Currency USD = Currency.getInstance("USD"); + private static final Currency KRW = Currency.getInstance("KRW"); + + @Test + @DisplayName("amount null을 거부한다.") + void shouldRejectNullAmount() { + assertThatNullPointerException() + .isThrownBy(() -> new Cost(null, USD)); + } + + @Test + @DisplayName("currency null을 거부한다.\n") + void shouldRejectNullCurrency() { + assertThatNullPointerException() + .isThrownBy(() -> new Cost(BigDecimal.ONE, null)); + } + + @Test + @DisplayName("usage cost는 음수를 거부한다.\n") + void shouldRejectNegativeUsageCost() { + assertThatThrownBy(() -> Cost.of(new BigDecimal("-0.01"), USD)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("0 값을 만들 때 통화를 보존해야 한다.") + void shouldPreserveCurrencyWhenCreatingZeroCost() { + Cost cost = Cost.zero(KRW); + + assertThat(cost.amount()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(cost.currency()).isEqualTo(KRW); + } + + @Test + @DisplayName("null 비용을 추가하면 거부해야 한다.") + void shouldRejectAddWithNullCost() { + Cost cost = Cost.of(BigDecimal.ONE, USD); + + assertThatNullPointerException() + .isThrownBy(() -> cost.add(null)); + } + + @Test + @DisplayName("동일한 통화로 비용을 추가해야 한다.") + void shouldAddCostsWithSameCurrency() { + Cost first = Cost.of(new BigDecimal("1.25"), USD); + Cost second = Cost.of(new BigDecimal("2.75"), USD); + + Cost result = first.add(second); + + assertThat(result.amount()).isEqualByComparingTo("4.00"); + assertThat(result.currency()).isEqualTo(USD); + } + + @Test + @DisplayName("다른 통화로 비용을 추가하면 거부해야 한다.") + void shouldRejectAddWithDifferentCurrency() { + Cost usdCost = Cost.of(BigDecimal.ONE, USD); + Cost krwCost = Cost.of(BigDecimal.ONE, KRW); + + assertThatThrownBy(() -> usdCost.add(krwCost)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("null 비용과 비교하면 거부해야 한다.") + void shouldRejectCompareWithNullCost() { + Cost cost = Cost.of(BigDecimal.ONE, USD); + + assertThatNullPointerException() + .isThrownBy(() -> cost.compareTo(null)); + } + + @Test + @DisplayName("동일한 통화로 비용을 비교해야 한다.") + void shouldCompareCostsWithSameCurrency() { + Cost lower = Cost.of(new BigDecimal("1.25"), USD); + Cost higher = Cost.of(new BigDecimal("2.75"), USD); + Cost sameAmount = Cost.of(new BigDecimal("1.250"), USD); + + assertThat(lower.compareTo(higher)).isNegative(); + assertThat(higher.compareTo(lower)).isPositive(); + assertThat(lower.compareTo(sameAmount)).isZero(); + } + + @Test + @DisplayName("다른 통화로 비용을 비교하면 거부해야 한다.") + void shouldRejectCompareWithDifferentCurrency() { + Cost usdCost = Cost.of(BigDecimal.ONE, USD); + Cost krwCost = Cost.of(BigDecimal.ONE, KRW); + + assertThatThrownBy(() -> usdCost.compareTo(krwCost)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("수치적으로 동일한 금액은 동일하게 취급해야 한다.") + void shouldTreatNumericallyEqualAmountsAsEqualRegardlessOfScale() { + Cost oneDecimal = Cost.of(new BigDecimal("1.0"), USD); + Cost twoDecimals = Cost.of(new BigDecimal("1.00"), USD); + + assertThat(oneDecimal).isEqualTo(twoDecimals); + assertThat(oneDecimal.hashCode()).isEqualTo(twoDecimals.hashCode()); + } + + @Test + @DisplayName("수치적으로 동일하지만 다른 통화를 동일하게 취급하면 안된다.") + void shouldNotTreatSameAmountWithDifferentCurrencyAsEqual() { + Cost usdCost = Cost.of(new BigDecimal("1.00"), USD); + Cost krwCost = Cost.of(new BigDecimal("1.00"), KRW); + + assertThat(usdCost).isNotEqualTo(krwCost); + } + + @Test + @DisplayName("0.0000004 비용이 Cost 생성 시 0이 되지 않는다.") + void shouldTreatZeroCostAsZero() { + Cost cost = Cost.of(new BigDecimal("0.0000004"), USD); + + assertThat(cost.amount()).isEqualByComparingTo("0.0000004"); + assertThat(cost.amount()).isNotEqualByComparingTo(BigDecimal.ZERO); + } +} \ No newline at end of file diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java index b7737c4..5c359c7 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultCostCalculatorTest.java @@ -4,7 +4,7 @@ import io.tokenledger.core.domain.PricingPlan; import io.tokenledger.core.domain.TokenType; import io.tokenledger.core.domain.TokenUsage; -import org.junit.jupiter.api.BeforeEach; +import java.util.Currency; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -18,6 +18,8 @@ class DefaultCostCalculatorTest { private final DefaultCostCalculator calculator = new DefaultCostCalculator(); + private static final String DEFAULT_CURRENCY = "USD"; + @Test @DisplayName("기본 입력/출력 토큰 비용이 정확하게 계산되어야 한다") void calculateStandardTokens() { @@ -30,7 +32,7 @@ void calculateStandardTokens() { Cost cost = calculator.calculate(usage, plan); // Then: (1000 * 0.01 / 1000) + (2000 * 0.03 / 1000) = 0.01 + 0.06 = 0.07 - assertThat(cost.value()).isEqualByComparingTo("0.070000"); + assertThat(cost.amount()).isEqualByComparingTo("0.070000"); } @Test @@ -41,7 +43,7 @@ void calculateReasoningTokensWithDifferentRate() { rates.put(TokenType.PROMPT, new BigDecimal("0.015")); rates.put(TokenType.COMPLETION, new BigDecimal("0.060")); rates.put(TokenType.REASONING, new BigDecimal("0.045")); - PricingPlan plan = new PricingPlan("o1-preview", rates, Cost.DEFAULT_CURRENCY); + PricingPlan plan = new PricingPlan("o1-preview", rates, Currency.getInstance(DEFAULT_CURRENCY)); // Usage: Prompt 1000, Completion 500, Reasoning 1500 (Total 3000) Map counts = new EnumMap<>(TokenType.class); @@ -58,7 +60,7 @@ void calculateReasoningTokensWithDifferentRate() { // 500 * 0.060 / 1000 = 0.030 // 1500 * 0.045 / 1000 = 0.0675 // Total = 0.015 + 0.030 + 0.0675 = 0.1125 - assertThat(cost.value()).isEqualByComparingTo("0.112500"); + assertThat(cost.amount()).isEqualByComparingTo("0.112500"); } @Test @@ -73,6 +75,21 @@ void calculateReasoningTokensWithFallback() { Cost cost = calculator.calculate(usage, plan); // Then: 1000 * 0.01 / 1000 + 1000 * 0.03 / 1000 = 0.04 - assertThat(cost.value()).isEqualByComparingTo("0.040000"); + assertThat(cost.amount()).isEqualByComparingTo("0.040000"); + } + + @Test + @DisplayName("1,000 token은 1K 단가 그대로 계산되어야 한다") + void shouldUseRateAsCostForOneThousandTokens() { + PricingPlan plan = new PricingPlan( + "tiny-model", + new BigDecimal("0.0004"), + BigDecimal.ZERO + ); + TokenUsage usage = TokenUsage.from(1, 0); + + Cost cost = calculator.calculate(usage, plan); + + assertThat(cost.amount()).isEqualByComparingTo("0.0000004"); } } diff --git a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultLedgerManagerTest.java b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultLedgerManagerTest.java index f6e22ce..155a3d6 100644 --- a/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultLedgerManagerTest.java +++ b/token-ledger-core/src/test/java/io/tokenledger/core/internal/DefaultLedgerManagerTest.java @@ -38,7 +38,7 @@ void shouldRecordAndCalculateCost() { Cost cost = manager.record("gpt-4o", usage, Map.of()); - assertThat(cost.value()).isEqualByComparingTo("20.000000"); + assertThat(cost.amount()).isEqualByComparingTo("20.000000"); verify(listener).onRecord(argThat(event -> event.modelId().equals("gpt-4o") && event.usage().equals(usage) && @@ -53,7 +53,7 @@ void shouldReturnZeroCostWhenPlanIsMissing() { Cost result = manager.record("unknown-model", usage, Map.of()); - assertThat(result.value()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.amount()).isEqualByComparingTo(BigDecimal.ZERO); verify(listener).onRecord(any(CostRecordedEvent.class)); } }