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));
}
}