From 064238405ba61671c2fa14c99a1d340c2ef3d45e Mon Sep 17 00:00:00 2001 From: rigu1 Date: Fri, 24 Jul 2026 00:42:04 +0900 Subject: [PATCH 1/8] =?UTF-8?q?fix(core):=20Cost=20=EB=B6=88=EB=B3=80?= =?UTF-8?q?=EC=8B=9D=EA=B3=BC=20=EB=AA=85=EC=8B=9C=EC=A0=81=20zero=20?= =?UTF-8?q?=ED=86=B5=ED=99=94=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/io/tokenpilot/core/domain/Cost.java | 65 +++++++-- .../core/internal/DefaultLedgerManager.java | 6 +- .../io/tokenpilot/core/domain/CostTest.java | 136 ++++++++++++++++++ 3 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 token-pilot-core/src/test/java/io/tokenpilot/core/domain/CostTest.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/Cost.java b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/Cost.java index b96510a..e9d1e6c 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/domain/Cost.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/domain/Cost.java @@ -1,37 +1,78 @@ package io.tokenpilot.core.domain; 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 value 비용 (BigDecimal) + * @param currency 통화 (Currency) */ public record Cost( BigDecimal value, Currency currency ) { - public static final Currency DEFAULT_CURRENCY = Currency.getInstance("USD"); public Cost { - value = value.setScale(6, RoundingMode.HALF_UP); + Objects.requireNonNull(value, "value cant be null"); + Objects.requireNonNull(currency, "currency cant be null"); + + validateNonNegativeAmount(value); } - 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 = value.add(other.value); + + return new Cost(totalAmount, currency); + } + + public int compareTo(Cost other) { + Objects.requireNonNull(other, "cost cant be null"); + validateSameCurrency(other); + + return value.compareTo(other.value); + } + + 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 value.compareTo(other.value) == 0 + && currency.equals(other.currency); + } + + @Override + public int hashCode() { + return Objects.hash(value.stripTrailingZeros(), currency); } } diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java index e3e97b5..a41c508 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java @@ -3,6 +3,7 @@ import io.tokenpilot.core.*; import io.tokenpilot.core.domain.*; +import java.util.Currency; import java.util.List; import java.util.Map; import java.util.Optional; @@ -13,6 +14,9 @@ * 가격 저장소와 계산기를 조율하여 비용을 기록하고, 등록된 리스너들에게 이벤트를 전파합니다. */ class DefaultLedgerManager implements LedgerManager { + + static String DEFAULT_CURRENCY = "USD"; + private final PricingRegistry pricingRegistry; private final CostCalculator costCalculator; private final List listeners = new CopyOnWriteArrayList<>(); @@ -44,7 +48,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-pilot-core/src/test/java/io/tokenpilot/core/domain/CostTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/CostTest.java new file mode 100644 index 0000000..552fb31 --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/domain/CostTest.java @@ -0,0 +1,136 @@ +package io.tokenpilot.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.value()).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.value()).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.value()).isEqualByComparingTo("0.0000004"); + assertThat(cost.value()).isNotEqualByComparingTo(BigDecimal.ZERO); + } +} From 5db7183480484be9f42cb3a2a847c5b1052aa43c Mon Sep 17 00:00:00 2001 From: rigu1 Date: Fri, 24 Jul 2026 12:31:51 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix(core):=20=EB=B9=84=EC=9A=A9=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=A0=95=EB=B0=80=EB=8F=84=EC=99=80=20=EB=B0=98?= =?UTF-8?q?=EC=98=AC=EB=A6=BC=20=EC=A0=95=EC=B1=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/tokenpilot/core/RoundingPolicy.java | 25 ++++++++ .../core/internal/DefaultCostCalculator.java | 6 +- .../tokenpilot/core/RoundingPolicyTest.java | 57 +++++++++++++++++++ .../internal/DefaultCostCalculatorTest.java | 15 +++++ 4 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 token-pilot-core/src/main/java/io/tokenpilot/core/RoundingPolicy.java create mode 100644 token-pilot-core/src/test/java/io/tokenpilot/core/RoundingPolicyTest.java diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/RoundingPolicy.java b/token-pilot-core/src/main/java/io/tokenpilot/core/RoundingPolicy.java new file mode 100644 index 0000000..ddbce47 --- /dev/null +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/RoundingPolicy.java @@ -0,0 +1,25 @@ +package io.tokenpilot.core; + +import io.tokenpilot.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.value().setScale(scale, roundingMode), + cost.currency() + ); + } +} diff --git a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java index 079daf8..1ffb6ed 100644 --- a/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java +++ b/token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java @@ -7,16 +7,14 @@ import io.tokenpilot.core.domain.TokenUsage; import java.math.BigDecimal; -import java.math.RoundingMode; /** * 기본 비용 계산기 구현체. * 포괄 총량에서 cache/reasoning 세부량을 분리한 배타적 구간별로 * {@link TokenType} 단가를 적용하여 중복 없이 계산합니다. - * 1K 토큰당 가격 정보를 사용하여 소수점 10자리까지 중간 계산 후 6자리로 최종 반올림합니다. + * 1K 토큰당 가격 정보를 정확한 decimal shift로 계산합니다. */ class DefaultCostCalculator implements CostCalculator { - private static final BigDecimal THOUSAND = BigDecimal.valueOf(1000); @Override public Cost calculate(TokenUsage usage, PricingPlan plan) { @@ -42,7 +40,7 @@ private BigDecimal costFor(long count, BigDecimal rate) { } return rate.multiply(BigDecimal.valueOf(count)) - .divide(THOUSAND, 10, RoundingMode.HALF_UP); + .movePointLeft(3); } private long countOrZero(Long count) { diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/RoundingPolicyTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/RoundingPolicyTest.java new file mode 100644 index 0000000..3b0cd14 --- /dev/null +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/RoundingPolicyTest.java @@ -0,0 +1,57 @@ +package io.tokenpilot.core; + +import io.tokenpilot.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.value()).isEqualByComparingTo("0.000001"); + assertThat(roundedCost.value().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.value()).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.value()).isEqualByComparingTo("0.0004000"); + assertThat(rounded.value()).isEqualByComparingTo("0.000400"); + assertThat(rounded.value().scale()).isEqualTo(6); + assertThat(rounded.currency()).isEqualTo(USD); + } +} diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java index aff726f..17297f3 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java @@ -62,4 +62,19 @@ TokenType.REASONING, new BigDecimal("0.05") // 800 normal output + 200 reasoning output assertThat(cost.value()).isEqualByComparingTo("0.041050"); } + + @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.value()).isEqualByComparingTo("0.0000004"); + } } From 4513c8a707bd9d9043b484c0f12ce97a4b284a15 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Fri, 24 Jul 2026 13:45:25 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix(budget):=20=EC=98=88=EC=82=B0=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1=20=EA=B2=BD=EA=B3=84=EB=A5=BC=20Cost?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../TokenPilotAutoConfiguration.java | 36 +- .../TokenPilotAutoConfigurationTest.java | 336 ++++++++++-------- .../io/tokenpilot/budget/BudgetDecision.java | 14 +- .../io/tokenpilot/budget/BudgetEvaluator.java | 28 +- .../tokenpilot/budget/BudgetStateStore.java | 6 +- .../internal/DefaultBudgetEvaluator.java | 216 +++++------ .../internal/InMemoryBudgetStateStore.java | 33 +- .../internal/LedgerBudgetComponents.java | 3 +- .../internal/DefaultBudgetEvaluatorTest.java | 184 +++++----- .../notification/BudgetNotificationEvent.java | 5 +- .../BudgetNotificationServiceTest.java | 148 ++++---- .../tokenpilot/sample/SampleController.java | 13 +- .../internal/DefaultLedgerAdvisor.java | 2 +- .../internal/DefaultLedgerAdvisorTest.java | 38 +- 14 files changed, 558 insertions(+), 504 deletions(-) diff --git a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java index 5101b19..2ff614f 100644 --- a/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java +++ b/token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java @@ -9,11 +9,13 @@ import io.tokenpilot.core.LedgerManager; import io.tokenpilot.core.PricingProvider; import io.tokenpilot.core.PricingRegistry; +import io.tokenpilot.core.domain.Cost; import io.tokenpilot.core.internal.LedgerComponents; import io.tokenpilot.micrometer.internal.LedgerMicrometerComponents; import io.tokenpilot.springai.LedgerAdvisor; import io.tokenpilot.springai.UsageExtractor; import io.tokenpilot.springai.internal.LedgerSpringAiComponents; +import java.util.Currency; import org.springframework.ai.chat.client.ChatClient; import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfiguration; @@ -56,7 +58,8 @@ public PricingProvider pricingProvider(TokenPilotProperties properties) { @Bean @ConditionalOnMissingBean public PricingRegistry pricingRegistry(ObjectProvider pricingProviders) { - return LedgerComponents.inMemoryPricingRegistry(pricingProviders.orderedStream().toList()); + return LedgerComponents.inMemoryPricingRegistry(pricingProviders.orderedStream() + .toList()); } /** @@ -165,15 +168,24 @@ public BudgetStateStore budgetStateStore() { @ConditionalOnMissingBean @ConditionalOnClass(LedgerBudgetComponents.class) @ConditionalOnProperty(prefix = "token-pilot.budget", name = "enabled", havingValue = "true") - public BudgetEvaluator budgetEvaluator(BudgetStateStore budgetStateStore, TokenPilotProperties properties) { - return LedgerBudgetComponents.defaultBudgetEvaluator(budgetStateStore, properties.getBudget() - .getMonthlyLimit()); + public BudgetEvaluator budgetEvaluator( + BudgetStateStore budgetStateStore, + TokenPilotProperties properties + ) { + Cost monthlyLimit = Cost.of( + properties.getBudget() + .getMonthlyLimit(), + Currency.getInstance("USD") + ); + + return LedgerBudgetComponents.defaultBudgetEvaluator( + budgetStateStore, + monthlyLimit + ); } /** - * 알림 상태 저장소를 등록합니다. - * - 중복 알림 방지를 위해 window 단위로 상태를 저장 - * - token-pilot.notification.enabled=true 일 때만 등록 + * 알림 상태 저장소를 등록합니다. - 중복 알림 방지를 위해 window 단위로 상태를 저장 - token-pilot.notification.enabled=true 일 때만 등록 */ @Bean @ConditionalOnMissingBean @@ -183,18 +195,16 @@ public NotificationStateStore notificationStateStore() { } /** - * 예산 알림 서비스를 등록합니다. - * - BudgetNotificationHandler 빈이 있을 때만 등록 - * - 없으면 no-op으로 동작 (알림 서비스 자체가 등록되지 않음) - * - token-pilot.notification.enabled=true 일 때만 등록 + * 예산 알림 서비스를 등록합니다. - BudgetNotificationHandler 빈이 있을 때만 등록 - 없으면 no-op으로 동작 (알림 서비스 자체가 등록되지 않음) - + * token-pilot.notification.enabled=true 일 때만 등록 */ @Bean @ConditionalOnMissingBean @ConditionalOnBean(BudgetNotificationHandler.class) @ConditionalOnProperty(prefix = "token-pilot.notification", name = "enabled", havingValue = "true") public BudgetNotificationService budgetNotificationService( - BudgetNotificationHandler handler, - NotificationStateStore notificationStateStore + BudgetNotificationHandler handler, + NotificationStateStore notificationStateStore ) { return new BudgetNotificationService(handler, notificationStateStore); } diff --git a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java index 0717c1c..38183bb 100644 --- a/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java +++ b/token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java @@ -19,6 +19,7 @@ import io.tokenpilot.notification.NotificationStateStore; import io.tokenpilot.springai.LedgerAdvisor; import io.tokenpilot.springai.UsageExtractor; +import java.util.Currency; import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -52,8 +53,11 @@ class TokenPilotAutoConfigurationTest { private static final String PROP_COMPLETION = PREFIX + ".rates.COMPLETION"; private static final String PROP_CURRENCY = PREFIX + ".currency"; + private static final Currency USD = Currency.getInstance("USD"); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(TokenPilotAutoConfiguration.class)); + .withConfiguration(AutoConfigurations.of(TokenPilotAutoConfiguration.class)); @Test @DisplayName("기본 설정에서 Core 및 Spring AI 빈은 등록되고, Budget 빈은 등록되지 않아야 한다") @@ -89,231 +93,233 @@ void shouldRegisterDefaultPricingProviderWhenNoProperties() { @MethodSource("providePricingConfigs") @DisplayName("설정 프로퍼티가 PricingProvider 빈과 환경에 정확하게 바인딩되어야 한다") void shouldBindPropertiesToEnvironmentAndBean( - String modelId, - String promptRate, - String completionRate, - String currency + String modelId, + String promptRate, + String completionRate, + String currency ) { this.contextRunner - .withPropertyValues(buildProperties( - modelId, - promptRate, - completionRate, - currency - )) - .run(context -> { - assertThat(context).hasSingleBean(PricingProvider.class); - - var env = context.getEnvironment(); - var plans = context.getBean(PricingProvider.class) - .getAllPlans(); - - SoftAssertions.assertSoftly(softly -> { - softly.assertThat(env.getProperty(PROP_MODEL_ID)) - .isEqualTo(modelId); - softly.assertThat(env.getProperty(PROP_PROMPT)) - .isEqualTo(promptRate); - softly.assertThat(env.getProperty(PROP_COMPLETION)) - .isEqualTo(completionRate); - softly.assertThat(env.getProperty(PROP_CURRENCY)) - .isEqualTo(currency); - - softly.assertThat(plans) - .hasSize(1); - - PricingPlan plan = plans.iterator() - .next(); - - softly.assertThat(plan.modelId()) - .isEqualTo(modelId); - softly.assertThat(plan.currency() - .getCurrencyCode()) - .isEqualTo(currency); - softly.assertThat(plan.getRate(PROMPT)) - .isEqualByComparingTo(promptRate); - softly.assertThat(plan.getRate(COMPLETION)) - .isEqualByComparingTo(completionRate); + .withPropertyValues(buildProperties( + modelId, + promptRate, + completionRate, + currency + )) + .run(context -> { + assertThat(context).hasSingleBean(PricingProvider.class); + + var env = context.getEnvironment(); + var plans = context.getBean(PricingProvider.class) + .getAllPlans(); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(env.getProperty(PROP_MODEL_ID)) + .isEqualTo(modelId); + softly.assertThat(env.getProperty(PROP_PROMPT)) + .isEqualTo(promptRate); + softly.assertThat(env.getProperty(PROP_COMPLETION)) + .isEqualTo(completionRate); + softly.assertThat(env.getProperty(PROP_CURRENCY)) + .isEqualTo(currency); + + softly.assertThat(plans) + .hasSize(1); + + PricingPlan plan = plans.iterator() + .next(); + + softly.assertThat(plan.modelId()) + .isEqualTo(modelId); + softly.assertThat(plan.currency() + .getCurrencyCode()) + .isEqualTo(currency); + softly.assertThat(plan.getRate(PROMPT)) + .isEqualByComparingTo(promptRate); + softly.assertThat(plan.getRate(COMPLETION)) + .isEqualByComparingTo(completionRate); + }); }); - }); } @Test @DisplayName("설정된 가격 정책이 PricingRegistry와 LedgerManager 비용 계산에 연결되어야 한다") void shouldRegisterConfiguredPricingPlansInRegistry() { this.contextRunner - .withPropertyValues(buildProperties( - "gpt-4o", - "0.005", - "0.015", - "USD" - )) - .run(context -> { - PricingRegistry pricingRegistry = context.getBean(PricingRegistry.class); - LedgerManager ledgerManager = context.getBean(LedgerManager.class); - - var plan = pricingRegistry.getPlan("gpt-4o"); - Cost cost = ledgerManager.record( - "gpt-4o", - TokenUsage.from(1_000, 2_000), - Map.of() - ); - - SoftAssertions.assertSoftly(softly -> { - softly.assertThat(plan) - .isPresent(); - softly.assertThat(plan.orElseThrow() - .getRate(PROMPT)) - .isEqualByComparingTo("0.005"); - softly.assertThat(plan.orElseThrow() - .getRate(COMPLETION)) - .isEqualByComparingTo("0.015"); - softly.assertThat(cost.value()) - .isEqualByComparingTo("0.035000"); - softly.assertThat(cost.currency() - .getCurrencyCode()) - .isEqualTo("USD"); + .withPropertyValues(buildProperties( + "gpt-4o", + "0.005", + "0.015", + "USD" + )) + .run(context -> { + PricingRegistry pricingRegistry = context.getBean(PricingRegistry.class); + LedgerManager ledgerManager = context.getBean(LedgerManager.class); + + var plan = pricingRegistry.getPlan("gpt-4o"); + Cost cost = ledgerManager.record( + "gpt-4o", + TokenUsage.from(1_000, 2_000), + Map.of() + ); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(plan) + .isPresent(); + softly.assertThat(plan.orElseThrow() + .getRate(PROMPT)) + .isEqualByComparingTo("0.005"); + softly.assertThat(plan.orElseThrow() + .getRate(COMPLETION)) + .isEqualByComparingTo("0.015"); + softly.assertThat(cost.value()) + .isEqualByComparingTo("0.035000"); + softly.assertThat(cost.currency() + .getCurrencyCode()) + .isEqualTo("USD"); + }); }); - }); } @Test @DisplayName("token-pilot.budget.enabled=true 일 때 Budget 관련 빈이 등록되어야 한다") void shouldRegisterBudgetBeansWhenEnabled() { this.contextRunner - .withPropertyValues("token-pilot.budget.enabled=true") - .run(context -> { - assertThat(context).hasSingleBean(BudgetStateStore.class); - assertThat(context).hasSingleBean(BudgetEvaluator.class); - }); + .withPropertyValues("token-pilot.budget.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(BudgetStateStore.class); + assertThat(context).hasSingleBean(BudgetEvaluator.class); + }); } @Test @DisplayName("Budget가 활성화되면 LedgerAdvisor가 BudgetEvaluator를 사용해야 한다") void shouldWireBudgetEvaluatorIntoLedgerAdvisorWhenBudgetEnabled() { this.contextRunner - .withUserConfiguration(RecordingBudgetEvaluatorConfiguration.class) - .withPropertyValues("token-pilot.budget.enabled=true") - .run(context -> { - LedgerAdvisor advisor = context.getBean(LedgerAdvisor.class); - RecordingBudgetEvaluator evaluator = context.getBean(RecordingBudgetEvaluator.class); - - ChatClientRequest request = mock(ChatClientRequest.class); - when(request.context()).thenReturn(Map.of("tenant_id", "tenant-abc")); - - advisor.before(request, mock(AdvisorChain.class)); - - SoftAssertions.assertSoftly(softly -> { - softly.assertThat(evaluator.evaluateCalls()) - .isEqualTo(1); - softly.assertThat(evaluator.lastTags()) - .containsEntry("tenant_id", "tenant-abc"); + .withUserConfiguration(RecordingBudgetEvaluatorConfiguration.class) + .withPropertyValues("token-pilot.budget.enabled=true") + .run(context -> { + LedgerAdvisor advisor = context.getBean(LedgerAdvisor.class); + RecordingBudgetEvaluator evaluator = context.getBean(RecordingBudgetEvaluator.class); + + ChatClientRequest request = mock(ChatClientRequest.class); + when(request.context()).thenReturn(Map.of("tenant_id", "tenant-abc")); + + advisor.before(request, mock(AdvisorChain.class)); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(evaluator.evaluateCalls()) + .isEqualTo(1); + softly.assertThat(evaluator.lastTags()) + .containsEntry("tenant_id", "tenant-abc"); + }); }); - }); } @Test @DisplayName("token-pilot.budget.enabled=false 일 때 Budget 관련 빈이 등록되지 않아야 한다") void shouldNotRegisterBudgetBeansWhenDisabled() { this.contextRunner - .withPropertyValues("token-pilot.budget.enabled=false") - .run(context -> { - assertThat(context).doesNotHaveBean(BudgetStateStore.class); - assertThat(context).doesNotHaveBean(BudgetEvaluator.class); - }); + .withPropertyValues("token-pilot.budget.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(BudgetStateStore.class); + assertThat(context).doesNotHaveBean(BudgetEvaluator.class); + }); } @Test @DisplayName("MeterRegistry가 존재할 때 Micrometer 관련 빈이 등록되어야 한다") void shouldRegisterMicrometerBeanWhenMeterRegistryExists() { this.contextRunner - .withUserConfiguration(MeterRegistryConfiguration.class) - .run(context -> { - assertThat(context).hasBean("microCostMetricsPublisher"); - }); + .withUserConfiguration(MeterRegistryConfiguration.class) + .run(context -> { + assertThat(context).hasBean("microCostMetricsPublisher"); + }); } @Test @DisplayName("token-pilot.metrics.enabled=false 일 때 Micrometer 관련 빈이 등록되지 않아야 한다") void shouldNotRegisterMicrometerBeanWhenMetricsDisabled() { this.contextRunner - .withUserConfiguration(MeterRegistryConfiguration.class) - .withPropertyValues("token-pilot.metrics.enabled=false") - .run(context -> { - assertThat(context).doesNotHaveBean("microCostMetricsPublisher"); - }); + .withUserConfiguration(MeterRegistryConfiguration.class) + .withPropertyValues("token-pilot.metrics.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean("microCostMetricsPublisher"); + }); } @Test @DisplayName("사용자 정의 빈이 있으면 자동 설정 빈이 덮어쓰지 않아야 한다") void shouldNotOverrideUserDefinedBeans() { this.contextRunner - .withUserConfiguration(UserCustomConfiguration.class) - .run(context -> { - assertThat(context).hasSingleBean(PricingRegistry.class); - assertThat(context.getBean(PricingRegistry.class)) - .isInstanceOf(UserCustomPricingRegistry.class); - }); + .withUserConfiguration(UserCustomConfiguration.class) + .run(context -> { + assertThat(context).hasSingleBean(PricingRegistry.class); + assertThat(context.getBean(PricingRegistry.class)) + .isInstanceOf(UserCustomPricingRegistry.class); + }); } @Test @DisplayName("token-pilot.notification.enabled=false 일 때 Notification 관련 빈이 등록되지 않아야 한다") void shouldNotRegisterNotificationBeansWhenDisabled() { this.contextRunner - .withPropertyValues("token-pilot.notification.enabled=false") - .run(context -> { - assertThat(context).doesNotHaveBean(NotificationStateStore.class); - assertThat(context).doesNotHaveBean(BudgetNotificationService.class); - }); + .withPropertyValues("token-pilot.notification.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(NotificationStateStore.class); + assertThat(context).doesNotHaveBean(BudgetNotificationService.class); + }); } @Test @DisplayName("notification이 활성화되어도 BudgetNotificationHandler 빈이 없으면 서비스가 등록되지 않아야 한다") void shouldNotRegisterNotificationServiceWhenHandlerMissing() { this.contextRunner - .withPropertyValues("token-pilot.notification.enabled=true") - .run(context -> { - assertThat(context).hasSingleBean(NotificationStateStore.class); - assertThat(context).doesNotHaveBean(BudgetNotificationService.class); - }); + .withPropertyValues("token-pilot.notification.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(NotificationStateStore.class); + assertThat(context).doesNotHaveBean(BudgetNotificationService.class); + }); } @Test @DisplayName("notification이 활성화되고 BudgetNotificationHandler 빈이 있을 때 BudgetNotificationService가 등록되어야 한다") void shouldRegisterNotificationServiceWhenEnabledAndHandlerExists() { this.contextRunner - .withUserConfiguration(FakeBudgetNotificationHandlerConfiguration.class) - .withPropertyValues("token-pilot.notification.enabled=true") - .run(context -> { - assertThat(context).hasSingleBean(NotificationStateStore.class); - assertThat(context).hasSingleBean(BudgetNotificationService.class); - assertThat(context.getBean(TokenPilotProperties.class).getNotification().isEnabled()) - .isTrue(); - }); + .withUserConfiguration(FakeBudgetNotificationHandlerConfiguration.class) + .withPropertyValues("token-pilot.notification.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(NotificationStateStore.class); + assertThat(context).hasSingleBean(BudgetNotificationService.class); + assertThat(context.getBean(TokenPilotProperties.class) + .getNotification() + .isEnabled()) + .isTrue(); + }); } private static Stream providePricingConfigs() { return Stream.of( - argumentSet( - "OpenAI GPT-4o 표준 설정", - "gpt-4o", - "0.005", - "0.015", - "USD"), - argumentSet( - "Anthropic Claude-3 EUR 설정", - "claude-3", - "0.01", - "0.03", - "EUR") + argumentSet( + "OpenAI GPT-4o 표준 설정", + "gpt-4o", + "0.005", + "0.015", + "USD"), + argumentSet( + "Anthropic Claude-3 EUR 설정", + "claude-3", + "0.01", + "0.03", + "EUR") ); } private String[] buildProperties(String modelId, String promptRate, String completionRate, String currency) { return new String[]{ - PROP_MODEL_ID + "=" + modelId, - PROP_PROMPT + "=" + promptRate, - PROP_COMPLETION + "=" + completionRate, - PROP_CURRENCY + "=" + currency + PROP_MODEL_ID + "=" + modelId, + PROP_PROMPT + "=" + promptRate, + PROP_COMPLETION + "=" + completionRate, + PROP_CURRENCY + "=" + currency }; } @@ -326,8 +332,14 @@ public PricingRegistry pricingRegistry() { } static class UserCustomPricingRegistry implements PricingRegistry { - @Override public void registerPlan(PricingPlan plan) {} - @Override public Optional getPlan(String modelId) { return Optional.empty(); } + @Override + public void registerPlan(PricingPlan plan) { + } + + @Override + public Optional getPlan(String modelId) { + return Optional.empty(); + } } @Configuration(proxyBeanMethods = false) @@ -346,11 +358,16 @@ static class RecordingBudgetEvaluator implements BudgetEvaluator { public BudgetDecision evaluate(Map tags) { this.evaluateCalls++; this.lastTags = tags; - return new BudgetDecision(BudgetState.ALLOW, BudgetThreshold.NONE, "allowed", BigDecimal.ZERO, BigDecimal.TEN); + return new BudgetDecision( + BudgetState.ALLOW, + BudgetThreshold.NONE, + "allowed", + usd("0"), + usd("10")); } @Override - public BudgetDecision evaluate(Map tags, BigDecimal costAmount) { + public BudgetDecision evaluate(Map tags, Cost cost) { return evaluate(tags); } @@ -376,7 +393,12 @@ public MeterRegistry meterRegistry() { static class FakeBudgetNotificationHandlerConfiguration { @Bean public BudgetNotificationHandler budgetNotificationHandler() { - return event -> {}; + return event -> { + }; } } + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } } diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetDecision.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetDecision.java index 1b6cd01..9bb9e45 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetDecision.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetDecision.java @@ -1,10 +1,10 @@ package io.tokenpilot.budget; -import java.math.BigDecimal; +import io.tokenpilot.core.domain.Cost; /** * 예산 평가 결과를 나타내는 객체 - * + *

* - state: ALLOW / WARN / BLOCK 상태 * - threshold: 현재 도달한 예산 임계치 * - reason: 상태 설명 @@ -12,9 +12,9 @@ * - limit: 총 예산 */ public record BudgetDecision( - BudgetState state, - BudgetThreshold threshold, - String reason, - BigDecimal currentUsage, - BigDecimal limit + BudgetState state, + BudgetThreshold threshold, + String reason, + Cost currentUsage, + Cost limit ) {} diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetEvaluator.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetEvaluator.java index 101eb72..2182623 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetEvaluator.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetEvaluator.java @@ -1,28 +1,26 @@ package io.tokenpilot.budget; -import java.math.BigDecimal; +import io.tokenpilot.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-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java index 2fc475f..e45bf60 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 @@ -1,6 +1,8 @@ package io.tokenpilot.budget; +import io.tokenpilot.core.domain.Cost; import java.math.BigDecimal; +import java.util.Currency; import java.util.Map; @@ -13,7 +15,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-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java index f6659f3..7b4ee53 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java @@ -7,121 +7,129 @@ import io.tokenpilot.budget.BudgetThreshold; import io.tokenpilot.budget.exception.BudgetExceededException; +import io.tokenpilot.core.domain.Cost; import java.math.BigDecimal; import java.util.Map; public class DefaultBudgetEvaluator implements BudgetEvaluator { - private final BudgetStateStore store; - private final BigDecimal monthlyLimit; - - public DefaultBudgetEvaluator( - BudgetStateStore store, - BigDecimal monthlyLimit - ) { - this.store = store; - this.monthlyLimit = monthlyLimit; - } - - @Override - public BudgetDecision evaluate( - Map tags, - BigDecimal costAmount - ) { - - BigDecimal currentUsage = store.getAccumulatedCost(tags); - BigDecimal newUsage = currentUsage.add(costAmount); - - BigDecimal halfThreshold = monthlyLimit.multiply(BigDecimal.valueOf(0.5)); - BigDecimal warningThreshold = monthlyLimit.multiply(BigDecimal.valueOf(0.8)); - - if (newUsage.compareTo(monthlyLimit) >= 0) { - BudgetDecision decision = new BudgetDecision( - BudgetState.BLOCK, - BudgetThreshold.EXCEEDED, - "월 예산을 초과했습니다", - newUsage, - monthlyLimit - ); - throw new BudgetExceededException(decision); - } - - // ✅ 80% 이상 - if (newUsage.compareTo(warningThreshold) >= 0) { - return new BudgetDecision( - BudgetState.WARN, - BudgetThreshold.WARNING, - "월 예산의 80% 이상 사용", - newUsage, - monthlyLimit - ); - } + private final BudgetStateStore store; + private final Cost monthlyLimit; - // ✅ 50% 이상 - if (newUsage.compareTo(halfThreshold) >= 0) { - return new BudgetDecision( - BudgetState.ALLOW, - BudgetThreshold.HALF, - "월 예산의 50% 이상 사용", - newUsage, - monthlyLimit - ); + public DefaultBudgetEvaluator( + BudgetStateStore store, + Cost monthlyLimit + ) { + this.store = store; + this.monthlyLimit = monthlyLimit; } - // ✅ 정상 - return new BudgetDecision( - BudgetState.ALLOW, - BudgetThreshold.NONE, - "정상 범위 사용", - newUsage, - monthlyLimit - ); - } - - @Override - public BudgetDecision evaluate(Map tags) { - - BigDecimal usage = store.getAccumulatedCost(tags); - - BigDecimal halfThreshold = monthlyLimit.multiply(BigDecimal.valueOf(0.5)); - BigDecimal warningThreshold = monthlyLimit.multiply(BigDecimal.valueOf(0.8)); - - if (usage.compareTo(monthlyLimit) >= 0) { - return new BudgetDecision( - BudgetState.BLOCK, - BudgetThreshold.EXCEEDED, - "월 예산을 초과했습니다", - usage, - monthlyLimit - ); + @Override + public BudgetDecision evaluate( + Map tags, + Cost cost + ) { + + Cost currentUsage = store.getAccumulatedCost(tags, monthlyLimit.currency()); + Cost newUsage = currentUsage.add(cost); + + Cost halfThreshold = threshold(BigDecimal.valueOf(0.5)); + Cost warningThreshold = threshold(BigDecimal.valueOf(0.8)); + + if (newUsage.compareTo(monthlyLimit) >= 0) { + BudgetDecision decision = new BudgetDecision( + BudgetState.BLOCK, + BudgetThreshold.EXCEEDED, + "월 예산을 초과했습니다", + newUsage, + monthlyLimit + ); + throw new BudgetExceededException(decision); + } + + // ✅ 80% 이상 + if (newUsage.compareTo(warningThreshold) >= 0) { + return new BudgetDecision( + BudgetState.WARN, + BudgetThreshold.WARNING, + "월 예산의 80% 이상 사용", + newUsage, + monthlyLimit + ); + } + + // ✅ 50% 이상 + if (newUsage.compareTo(halfThreshold) >= 0) { + return new BudgetDecision( + BudgetState.ALLOW, + BudgetThreshold.HALF, + "월 예산의 50% 이상 사용", + newUsage, + monthlyLimit + ); + } + + // ✅ 정상 + return new BudgetDecision( + BudgetState.ALLOW, + BudgetThreshold.NONE, + "정상 범위 사용", + newUsage, + monthlyLimit + ); } - if (usage.compareTo(warningThreshold) >= 0) { - return new BudgetDecision( - BudgetState.WARN, - BudgetThreshold.WARNING, - "월 예산의 80% 이상 사용", - usage, - monthlyLimit - ); + @Override + public BudgetDecision evaluate(Map tags) { + + Cost usage = store.getAccumulatedCost(tags, monthlyLimit.currency()); + + Cost halfThreshold = threshold(BigDecimal.valueOf(0.5)); + Cost warningThreshold = threshold(BigDecimal.valueOf(0.8)); + + if (usage.compareTo(monthlyLimit) >= 0) { + return new BudgetDecision( + BudgetState.BLOCK, + BudgetThreshold.EXCEEDED, + "월 예산을 초과했습니다", + usage, + monthlyLimit + ); + } + + if (usage.compareTo(warningThreshold) >= 0) { + return new BudgetDecision( + BudgetState.WARN, + BudgetThreshold.WARNING, + "월 예산의 80% 이상 사용", + usage, + monthlyLimit + ); + } + + if (usage.compareTo(halfThreshold) >= 0) { + return new BudgetDecision( + BudgetState.ALLOW, + BudgetThreshold.HALF, + "월 예산의 50% 이상 사용", + usage, + monthlyLimit + ); + } + + return new BudgetDecision( + BudgetState.ALLOW, + BudgetThreshold.NONE, + "정상 범위 사용", + usage, + monthlyLimit + ); } - if (usage.compareTo(halfThreshold) >= 0) { - return new BudgetDecision( - BudgetState.ALLOW, - BudgetThreshold.HALF, - "월 예산의 50% 이상 사용", - usage, - monthlyLimit - ); + private Cost threshold(BigDecimal rate) { + return Cost.of( + monthlyLimit.value().multiply(rate), + monthlyLimit.currency() + ); } - - return new BudgetDecision( - BudgetState.ALLOW, - BudgetThreshold.NONE, - "정상 범위 사용", - usage, - monthlyLimit - ); - } } 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 ea68db9..4c056e5 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 @@ -2,35 +2,34 @@ import io.tokenpilot.budget.BudgetStateStore; +import io.tokenpilot.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-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 2cb5bbc..a019636 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 @@ -2,6 +2,7 @@ import io.tokenpilot.budget.BudgetEvaluator; import io.tokenpilot.budget.BudgetStateStore; +import io.tokenpilot.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-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java index 1a1cd90..45d9b6f 100644 --- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java @@ -5,10 +5,13 @@ import io.tokenpilot.budget.BudgetThreshold; import io.tokenpilot.budget.BudgetStateStore; import io.tokenpilot.budget.exception.BudgetExceededException; +import io.tokenpilot.core.domain.Cost; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.math.BigDecimal; +import java.util.Currency; import java.util.Map; import static org.assertj.core.api.Assertions.*; @@ -16,102 +19,87 @@ class DefaultBudgetEvaluatorTest { - private BudgetStateStore store; - private DefaultBudgetEvaluator evaluator; - - private static final BigDecimal MONTHLY_LIMIT = new BigDecimal("100.00"); - private static final Map TAGS = Map.of("service", "test"); - - @BeforeEach - void setUp() { - store = mock(BudgetStateStore.class); - evaluator = new DefaultBudgetEvaluator(store, MONTHLY_LIMIT); - } - - // ─── evaluate(tags, costAmount) ─────────────────────────────────────────── - - @Test - void 정상_범위_사용() { - when(store.getAccumulatedCost(TAGS)).thenReturn(new BigDecimal("10.00")); - - BudgetDecision result = evaluator.evaluate(TAGS, new BigDecimal("10.00")); - - assertThat(result.state()).isEqualTo(BudgetState.ALLOW); - assertThat(result.threshold()).isEqualTo(BudgetThreshold.NONE); - } - - @Test - void 누적_50퍼센트_이상() { - when(store.getAccumulatedCost(TAGS)).thenReturn(new BigDecimal("40.00")); - - BudgetDecision result = evaluator.evaluate(TAGS, new BigDecimal("10.00")); - - assertThat(result.state()).isEqualTo(BudgetState.ALLOW); - assertThat(result.threshold()).isEqualTo(BudgetThreshold.HALF); - } - - @Test - void 누적_80퍼센트_이상() { - when(store.getAccumulatedCost(TAGS)).thenReturn(new BigDecimal("70.00")); - - BudgetDecision result = evaluator.evaluate(TAGS, new BigDecimal("10.00")); - - assertThat(result.state()).isEqualTo(BudgetState.WARN); - assertThat(result.threshold()).isEqualTo(BudgetThreshold.WARNING); - } - - @Test - void 예산_초과시_예외_발생() { - when(store.getAccumulatedCost(TAGS)).thenReturn(new BigDecimal("95.00")); - - assertThatThrownBy(() -> evaluator.evaluate(TAGS, new BigDecimal("10.00"))) - .isInstanceOf(BudgetExceededException.class) - .extracting(e -> ((BudgetExceededException) e).getDecision()) - .satisfies(decision -> { - assertThat(decision.state()).isEqualTo(BudgetState.BLOCK); - assertThat(decision.threshold()).isEqualTo(BudgetThreshold.EXCEEDED); - }); - } - - // ─── evaluate(tags) ─────────────────────────────────────────────────────── - - @Test - void 현재_사용량_정상() { - when(store.getAccumulatedCost(TAGS)).thenReturn(new BigDecimal("30.00")); - - BudgetDecision result = evaluator.evaluate(TAGS); - - assertThat(result.state()).isEqualTo(BudgetState.ALLOW); - assertThat(result.threshold()).isEqualTo(BudgetThreshold.NONE); - } - - @Test - void 현재_사용량_50퍼센트_이상() { - when(store.getAccumulatedCost(TAGS)).thenReturn(new BigDecimal("50.00")); - - BudgetDecision result = evaluator.evaluate(TAGS); - - assertThat(result.state()).isEqualTo(BudgetState.ALLOW); - assertThat(result.threshold()).isEqualTo(BudgetThreshold.HALF); - } - - @Test - void 현재_사용량_80퍼센트_이상() { - when(store.getAccumulatedCost(TAGS)).thenReturn(new BigDecimal("80.00")); - - BudgetDecision result = evaluator.evaluate(TAGS); - - assertThat(result.state()).isEqualTo(BudgetState.WARN); - assertThat(result.threshold()).isEqualTo(BudgetThreshold.WARNING); - } - - @Test - void 현재_사용량_100퍼센트_이상() { - when(store.getAccumulatedCost(TAGS)).thenReturn(new BigDecimal("100.00")); - - BudgetDecision result = evaluator.evaluate(TAGS); - - assertThat(result.state()).isEqualTo(BudgetState.BLOCK); - assertThat(result.threshold()).isEqualTo(BudgetThreshold.EXCEEDED); - } + private BudgetStateStore store; + private DefaultBudgetEvaluator evaluator; + + private static final Currency USD = Currency.getInstance("USD"); + private static final Cost MONTHLY_LIMIT = usd("100.00"); + private static final Map TAGS = Map.of("service", "test"); + + @BeforeEach + void setUp() { + store = mock(BudgetStateStore.class); + evaluator = new DefaultBudgetEvaluator(store, MONTHLY_LIMIT); + } + + @Test + @DisplayName("사용량이 50퍼센트 미만이면 ALLOW/NONE이다") + void shouldReturnAllowAndNoneWhenUsageIsBelow50Percent() { + when(store.getAccumulatedCost(TAGS, USD)) + .thenReturn(usd("10.00"), usd("30.00")); + + BudgetDecision withCost = evaluator.evaluate(TAGS, usd("10.00")); + BudgetDecision currentOnly = evaluator.evaluate(TAGS); + + assertDecision(withCost, BudgetState.ALLOW, BudgetThreshold.NONE); + assertDecision(currentOnly, BudgetState.ALLOW, BudgetThreshold.NONE); + } + + @Test + @DisplayName("사용량이 50퍼센트 이상이면 ALLOW/HALF이다") + void shouldReturnAllowAndHalfWhenUsageIsAtLeast50Percent() { + when(store.getAccumulatedCost(TAGS, USD)) + .thenReturn(usd("40.00"), usd("50.00")); + + BudgetDecision withCost = evaluator.evaluate(TAGS, usd("10.00")); + BudgetDecision currentOnly = evaluator.evaluate(TAGS); + + assertDecision(withCost, BudgetState.ALLOW, BudgetThreshold.HALF); + assertDecision(currentOnly, BudgetState.ALLOW, BudgetThreshold.HALF); + } + + @Test + @DisplayName("사용량이 80퍼센트 이상이면 WARN/WARNING이다") + void shouldReturnWarnAndWarningWhenUsageIsAtLeast80Percent() { + when(store.getAccumulatedCost(TAGS, USD)) + .thenReturn(usd("70.00"), usd("80.00")); + + BudgetDecision withCost = evaluator.evaluate(TAGS, usd("10.00")); + BudgetDecision currentOnly = evaluator.evaluate(TAGS); + + assertDecision(withCost, BudgetState.WARN, BudgetThreshold.WARNING); + assertDecision(currentOnly, BudgetState.WARN, BudgetThreshold.WARNING); + } + + @Test + @DisplayName("사용량이 100퍼센트 이상이면 BLOCK/EXCEEDED이다") + void shouldThrowBudgetExceededExceptionWhenUsageIsAtLeast100Percent() { + when(store.getAccumulatedCost(TAGS, USD)) + .thenReturn(usd("95.00"), usd("100.00")); + + assertThatThrownBy(() -> evaluator.evaluate(TAGS, usd("10.00"))) + .isInstanceOf(BudgetExceededException.class) + .extracting(e -> ((BudgetExceededException) e).getDecision()) + .satisfies(decision -> { + assertThat(decision.state()).isEqualTo(BudgetState.BLOCK); + assertThat(decision.threshold()).isEqualTo(BudgetThreshold.EXCEEDED); + }); + + BudgetDecision currentOnly = evaluator.evaluate(TAGS); + + assertDecision(currentOnly, BudgetState.BLOCK, BudgetThreshold.EXCEEDED); + } + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } + + private static void assertDecision( + BudgetDecision decision, + BudgetState state, + BudgetThreshold threshold + ) { + assertThat(decision.state()).isEqualTo(state); + assertThat(decision.threshold()).isEqualTo(threshold); + } } diff --git a/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java b/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java index 5fa760a..d6d47eb 100644 --- a/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java +++ b/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java @@ -3,6 +3,7 @@ import io.tokenpilot.budget.BudgetState; import io.tokenpilot.budget.BudgetThreshold; +import io.tokenpilot.core.domain.Cost; import java.math.BigDecimal; import java.util.Map; @@ -15,7 +16,7 @@ public record BudgetNotificationEvent( BudgetThreshold threshold, BudgetState state, String reason, - BigDecimal currentUsage, - BigDecimal limit, + Cost currentUsage, + Cost limit, Map tags ) {} \ No newline at end of file diff --git a/token-pilot-notification/src/test/java/io/tokenpilot/notification/BudgetNotificationServiceTest.java b/token-pilot-notification/src/test/java/io/tokenpilot/notification/BudgetNotificationServiceTest.java index 688ff94..f55554a 100644 --- a/token-pilot-notification/src/test/java/io/tokenpilot/notification/BudgetNotificationServiceTest.java +++ b/token-pilot-notification/src/test/java/io/tokenpilot/notification/BudgetNotificationServiceTest.java @@ -3,6 +3,8 @@ import io.tokenpilot.budget.BudgetDecision; import io.tokenpilot.budget.BudgetState; import io.tokenpilot.budget.BudgetThreshold; +import io.tokenpilot.core.domain.Cost; +import java.util.Currency; import org.junit.jupiter.api.Test; import java.math.BigDecimal; @@ -15,98 +17,104 @@ */ class BudgetNotificationServiceTest { - @Test - void threshold_increase_triggers_event_only_once_per_window() { + private static final Currency USD = Currency.getInstance("USD"); - BudgetNotificationHandler handler = mock(BudgetNotificationHandler.class); - NotificationStateStore store = new InMemoryNotificationStateStore(); + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } - BudgetNotificationService service = - new BudgetNotificationService(handler, store); + @Test + void threshold_increase_triggers_event_only_once_per_window() { - String targetId = "user1"; - String window = "2026-06"; + BudgetNotificationHandler handler = mock(BudgetNotificationHandler.class); + NotificationStateStore store = new InMemoryNotificationStateStore(); - BudgetDecision decision50 = new BudgetDecision( - BudgetState.WARN, - BudgetThreshold.HALF, - "50%", - BigDecimal.valueOf(50), - BigDecimal.valueOf(100) - ); + BudgetNotificationService service = + new BudgetNotificationService(handler, store); - BudgetDecision decision80 = new BudgetDecision( - BudgetState.WARN, - BudgetThreshold.WARNING, - "80%", - BigDecimal.valueOf(80), - BigDecimal.valueOf(100) - ); + String targetId = "user1"; + String window = "2026-06"; - // 50% 최초 → 발생 - service.notifyIfNeeded(decision50, targetId, window, Map.of()); + BudgetDecision decision50 = new BudgetDecision( + BudgetState.WARN, + BudgetThreshold.HALF, + "50%", + usd("50"), + usd("100") + ); - // 50% 반복 → 발생 안됨 - service.notifyIfNeeded(decision50, targetId, window, Map.of()); + BudgetDecision decision80 = new BudgetDecision( + BudgetState.WARN, + BudgetThreshold.WARNING, + "80%", + usd("80"), + usd("100") + ); - // 80% → 발생 - service.notifyIfNeeded(decision80, targetId, window, Map.of()); + // 50% 최초 → 발생 + service.notifyIfNeeded(decision50, targetId, window, Map.of()); - // 총 2번 발생해야 함 (50, 80) - verify(handler, times(2)).handle(any()); - } + // 50% 반복 → 발생 안됨 + service.notifyIfNeeded(decision50, targetId, window, Map.of()); - @Test - void same_threshold_should_not_trigger_duplicate_event() { + // 80% → 발생 + service.notifyIfNeeded(decision80, targetId, window, Map.of()); - BudgetNotificationHandler handler = mock(BudgetNotificationHandler.class); - NotificationStateStore store = new InMemoryNotificationStateStore(); + // 총 2번 발생해야 함 (50, 80) + verify(handler, times(2)).handle(any()); + } - BudgetNotificationService service = - new BudgetNotificationService(handler, store); + @Test + void same_threshold_should_not_trigger_duplicate_event() { - String targetId = "user1"; - String window = "2026-06"; + BudgetNotificationHandler handler = mock(BudgetNotificationHandler.class); + NotificationStateStore store = new InMemoryNotificationStateStore(); - BudgetDecision decision50 = new BudgetDecision( - BudgetState.WARN, - BudgetThreshold.HALF, - "50%", - BigDecimal.valueOf(50), - BigDecimal.valueOf(100) - ); + BudgetNotificationService service = + new BudgetNotificationService(handler, store); - service.notifyIfNeeded(decision50, targetId, window, Map.of()); - service.notifyIfNeeded(decision50, targetId, window, Map.of()); + String targetId = "user1"; + String window = "2026-06"; - verify(handler, times(1)).handle(any()); - } + BudgetDecision decision50 = new BudgetDecision( + BudgetState.WARN, + BudgetThreshold.HALF, + "50%", + usd("50"), + usd("100") + ); - @Test - void new_window_should_allow_notification_again() { + service.notifyIfNeeded(decision50, targetId, window, Map.of()); + service.notifyIfNeeded(decision50, targetId, window, Map.of()); - BudgetNotificationHandler handler = mock(BudgetNotificationHandler.class); - NotificationStateStore store = new InMemoryNotificationStateStore(); + verify(handler, times(1)).handle(any()); + } - BudgetNotificationService service = - new BudgetNotificationService(handler, store); + @Test + void new_window_should_allow_notification_again() { - String targetId = "user1"; + BudgetNotificationHandler handler = mock(BudgetNotificationHandler.class); + NotificationStateStore store = new InMemoryNotificationStateStore(); - BudgetDecision decision50 = new BudgetDecision( - BudgetState.WARN, - BudgetThreshold.HALF, - "50%", - BigDecimal.valueOf(50), - BigDecimal.valueOf(100) - ); + BudgetNotificationService service = + new BudgetNotificationService(handler, store); - // 6월 - service.notifyIfNeeded(decision50, targetId, "2026-06", Map.of()); + String targetId = "user1"; - // 7월 → 다시 발생해야 함 - service.notifyIfNeeded(decision50, targetId, "2026-07", Map.of()); + BudgetDecision decision50 = new BudgetDecision( + BudgetState.WARN, + BudgetThreshold.HALF, + "50%", + usd("50"), + usd("100") + ); - verify(handler, times(2)).handle(any()); - } + // 6월 + service.notifyIfNeeded(decision50, targetId, "2026-06", Map.of()); + + // 7월 → 다시 발생해야 함 + service.notifyIfNeeded(decision50, targetId, "2026-07", Map.of()); + + verify(handler, times(2)).handle(any()); + } } \ No newline at end of file diff --git a/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java b/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java index d901d32..fde968a 100644 --- a/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java +++ b/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java @@ -9,6 +9,7 @@ import io.tokenpilot.core.domain.TokenUsage; import java.math.BigDecimal; +import java.util.Currency; import java.util.Map; import org.springframework.beans.factory.ObjectProvider; @@ -19,6 +20,8 @@ @RestController public class SampleController { + private static final Currency USD = Currency.getInstance("USD"); + private final ApplicationContext applicationContext; private final LedgerManager ledgerManager; private final ObjectProvider budgetEvaluator; @@ -84,11 +87,11 @@ public Map budget() { } Map tags = Map.of("tenant_id", "budget-sample-tenant"); - BudgetDecision initialDecision = evaluator.evaluate(tags, new BigDecimal("0.001")); - stateStore.addCost(tags, new BigDecimal("0.0045")); + BudgetDecision initialDecision = evaluator.evaluate(tags, Cost.of(new BigDecimal("0.001"), USD)); + stateStore.addCost(tags, Cost.of(new BigDecimal("0.0045"), USD)); try { - evaluator.evaluate(tags, new BigDecimal("0.001")); + evaluator.evaluate(tags, Cost.of(new BigDecimal("0.001"), USD)); return Map.of( "enabled", "true", "initialState", initialDecision.state().name(), @@ -100,8 +103,8 @@ public Map budget() { "enabled", "true", "initialState", initialDecision.state().name(), "blockedState", blockedDecision.state().name(), - "currentUsage", blockedDecision.currentUsage().toPlainString(), - "limit", blockedDecision.limit().toPlainString() + "currentUsage", blockedDecision.currentUsage().value().toPlainString(), + "limit", blockedDecision.limit().value().toPlainString() ); } } diff --git a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java index 760f150..f3bd768 100644 --- a/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java +++ b/token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java @@ -68,7 +68,7 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) Optional plan = pricingRegistry.getPlan(modelId); if (plan.isPresent()) { Cost cost = costCalculator.calculate(usage, plan.get()); - budgetStateStore.addCost(tags, cost.value()); + budgetStateStore.addCost(tags, cost); } } diff --git a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java index 41028c1..96eef64 100644 --- a/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java +++ b/token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java @@ -41,9 +41,10 @@ void recordAfterAIResponse() { DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor(ledgerManager, extractor); ChatResponseMetadata metadata = ChatResponseMetadata.builder() - .model("gpt-4o") - .build(); - ChatResponse chatResponse = new ChatResponse(List.of(new Generation(new org.springframework.ai.chat.messages.AssistantMessage("test"))), metadata); + .model("gpt-4o") + .build(); + ChatResponse chatResponse = new ChatResponse( + List.of(new Generation(new org.springframework.ai.chat.messages.AssistantMessage("test"))), metadata); Map context = Map.of("user_id", "user-123", "tenant_id", "tenant-abc"); ChatClientResponse response = new ChatClientResponse(chatResponse, context); @@ -52,8 +53,10 @@ void recordAfterAIResponse() { verify(ledgerManager, times(1)).record( eq("gpt-4o"), eq(mockUsage), - argThat(tags -> tags.get("user_id").equals("user-123") && - tags.get("tenant_id").equals("tenant-abc")) + argThat(tags -> tags.get("user_id") + .equals("user-123") && + tags.get("tenant_id") + .equals("tenant-abc")) ); } @@ -67,26 +70,36 @@ void recordBudgetAfterAIResponse() { PricingRegistry pricingRegistry = mock(PricingRegistry.class); TokenUsage mockUsage = TokenUsage.from(100, 200); - PricingPlan mockPlan = new PricingPlan("gpt-4o", new BigDecimal("0.01"), new BigDecimal("0.03"), Currency.getInstance("USD")); + PricingPlan mockPlan = new PricingPlan("gpt-4o", new BigDecimal("0.01"), new BigDecimal("0.03"), + Currency.getInstance("USD")); Cost mockCost = new Cost(new BigDecimal("0.5"), Currency.getInstance("USD")); when(extractor.extract(any())).thenReturn(mockUsage); when(pricingRegistry.getPlan("gpt-4o")).thenReturn(Optional.of(mockPlan)); when(costCalculator.calculate(mockUsage, mockPlan)).thenReturn(mockCost); - DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor(ledgerManager, extractor, + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor(ledgerManager, extractor, null, budgetStateStore, costCalculator, pricingRegistry); - ChatResponseMetadata metadata = ChatResponseMetadata.builder().model("gpt-4o").build(); - ChatResponse chatResponse = new ChatResponse(List.of(new Generation(new org.springframework.ai.chat.messages.AssistantMessage("test"))), metadata); + ChatResponseMetadata metadata = ChatResponseMetadata.builder() + .model("gpt-4o") + .build(); + ChatResponse chatResponse = new ChatResponse( + List.of(new Generation(new org.springframework.ai.chat.messages.AssistantMessage("test"))), metadata); Map context = Map.of("tenant_id", "tenant-abc"); ChatClientResponse response = new ChatClientResponse(chatResponse, context); advisor.after(response, mock(AdvisorChain.class)); verify(budgetStateStore, times(1)).addCost( - argThat(tags -> tags.get("tenant_id").equals("tenant-abc")), - argThat(amount -> amount.compareTo(new BigDecimal("0.5")) == 0) + argThat(tags -> tags.get("tenant_id") + .equals("tenant-abc")), + argThat(cost -> + cost.value() + .compareTo(new BigDecimal("0.5")) == 0 + && cost.currency() + .equals(Currency.getInstance("USD")) + ) ); } @@ -104,7 +117,8 @@ void checkBudgetBeforeAIRequest() { advisor.before(request, mock(AdvisorChain.class)); verify(budgetEvaluator, times(1)).evaluate( - argThat(tags -> tags.get("tenant_id").equals("tenant-abc")) + argThat(tags -> tags.get("tenant_id") + .equals("tenant-abc")) ); } From 570e120ad0305f1f3ad3b13caa7d5c2e4af29d9e Mon Sep 17 00:00:00 2001 From: rigu1 Date: Fri, 24 Jul 2026 15:35:41 +0900 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20BigD?= =?UTF-8?q?ecimal=20import=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/io/tokenpilot/budget/BudgetStateStore.java | 1 - .../tokenpilot/budget/internal/InMemoryBudgetStateStore.java | 1 - .../io/tokenpilot/notification/BudgetNotificationEvent.java | 3 +-- 3 files changed, 1 insertion(+), 4 deletions(-) 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 e45bf60..f9a22bf 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 @@ -1,7 +1,6 @@ package io.tokenpilot.budget; import io.tokenpilot.core.domain.Cost; -import java.math.BigDecimal; import java.util.Currency; import java.util.Map; 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 4c056e5..d654b21 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 @@ -3,7 +3,6 @@ import io.tokenpilot.budget.BudgetStateStore; import io.tokenpilot.core.domain.Cost; -import java.math.BigDecimal; import java.util.Currency; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; diff --git a/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java b/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java index d6d47eb..c8861c1 100644 --- a/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java +++ b/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java @@ -4,7 +4,6 @@ import io.tokenpilot.budget.BudgetThreshold; import io.tokenpilot.core.domain.Cost; -import java.math.BigDecimal; import java.util.Map; /** @@ -19,4 +18,4 @@ public record BudgetNotificationEvent( Cost currentUsage, Cost limit, Map tags -) {} \ No newline at end of file +) {} From 9ea2cb7981c3b26e88138faa542820f3a26e5f80 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Fri, 24 Jul 2026 18:53:46 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix(sample):=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EA=B2=BD=EA=B3=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B0=98=EC=98=AC=EB=A6=BC=EC=9D=84=20=ED=95=9C=20=EB=B2=88?= =?UTF-8?q?=EB=A7=8C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tokenpilot/sample/CostBoundaryFormatter.java | 15 +++++++++++++++ .../io/tokenpilot/sample/SampleController.java | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 token-pilot-sample-app/src/main/java/io/tokenpilot/sample/CostBoundaryFormatter.java diff --git a/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/CostBoundaryFormatter.java b/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/CostBoundaryFormatter.java new file mode 100644 index 0000000..897818f --- /dev/null +++ b/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/CostBoundaryFormatter.java @@ -0,0 +1,15 @@ +package io.tokenpilot.sample; + +import io.tokenpilot.core.RoundingPolicy; +import io.tokenpilot.core.domain.Cost; + +final class CostBoundaryFormatter { + + private CostBoundaryFormatter() { + } + + static String format(Cost cost) { + Cost rounded = RoundingPolicy.COST_BOUNDARY_ROUNDING.apply(cost); + return rounded.value().toPlainString(); + } +} diff --git a/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java b/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java index fde968a..242bc72 100644 --- a/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java +++ b/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java @@ -72,7 +72,7 @@ public Map recordTokenPilotEvent() { return Map.of( "modelId", "gpt-4o-mini", - "cost", cost.value().toPlainString(), + "cost", CostBoundaryFormatter.format(cost), "currency", cost.currency().getCurrencyCode() ); } From 68640e70e5ece9fd00f62491f1c1787f9967b17f Mon Sep 17 00:00:00 2001 From: rigu1 Date: Fri, 24 Jul 2026 18:55:28 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix(budget):=20=ED=86=B5=ED=99=94=20?= =?UTF-8?q?=EB=B6=88=EC=9D=BC=EC=B9=98=20=ED=8F=89=EA=B0=80=EC=99=80=20?= =?UTF-8?q?=ED=86=B5=ED=99=94=EB=B3=84=20=EB=88=84=EC=A0=81=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/tokenpilot/budget/BudgetState.java | 3 +- .../internal/DefaultBudgetEvaluator.java | 20 ++++++++++- .../internal/InMemoryBudgetStateStore.java | 18 +++++----- .../internal/DefaultBudgetEvaluatorTest.java | 33 +++++++++++++++++++ .../InMemoryBudgetStateStoreTest.java | 33 +++++++++++++++++++ 5 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStoreTest.java diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetState.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetState.java index 443dc77..eac88de 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetState.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetState.java @@ -10,5 +10,6 @@ public enum BudgetState { ALLOW, WARN, - BLOCK + BLOCK, + CURRENCY_MISMATCH } diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java index 7b4ee53..eff5a70 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java @@ -29,8 +29,12 @@ public BudgetDecision evaluate( Map tags, Cost cost ) { - Cost currentUsage = store.getAccumulatedCost(tags, monthlyLimit.currency()); + + if (hasCurrencyMismatch(cost)) { + return currencyMismatchDecision(currentUsage); + } + Cost newUsage = currentUsage.add(cost); Cost halfThreshold = threshold(BigDecimal.valueOf(0.5)); @@ -132,4 +136,18 @@ private Cost threshold(BigDecimal rate) { monthlyLimit.currency() ); } + + private boolean hasCurrencyMismatch(Cost cost) { + return !monthlyLimit.currency().equals(cost.currency()); + } + + private BudgetDecision currencyMismatchDecision(Cost currentUsage) { + return new BudgetDecision( + BudgetState.CURRENCY_MISMATCH, + BudgetThreshold.NONE, + "예산 통화와 비용 통화가 일치하지 않습니다", + currentUsage, + monthlyLimit + ); + } } 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 d654b21..1955e4f 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,34 +1,34 @@ package io.tokenpilot.budget.internal; import io.tokenpilot.budget.BudgetStateStore; - import io.tokenpilot.core.domain.Cost; + 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 String key(Map tags) { - return tags.getOrDefault("tenant_id", "default"); - } - @Override public Cost getAccumulatedCost(Map tags, Currency currency) { - return store.getOrDefault(key(tags), Cost.zero(currency)); + return store.getOrDefault(key(tags, currency), Cost.zero(currency)); } @Override public void addCost(Map tags, Cost cost) { - store.merge(key(tags), cost, Cost::add); + store.merge(key(tags, cost.currency()), cost, Cost::add); + } + + private String key(Map tags, Currency currency) { + return tags.getOrDefault("tenant_id", "default") + + ":" + + currency.getCurrencyCode(); } } diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java index 45d9b6f..740a630 100644 --- a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java @@ -23,6 +23,8 @@ class DefaultBudgetEvaluatorTest { private DefaultBudgetEvaluator evaluator; private static final Currency USD = Currency.getInstance("USD"); + private static final Currency KRW = Currency.getInstance("KRW"); + private static final Cost MONTHLY_LIMIT = usd("100.00"); private static final Map TAGS = Map.of("service", "test"); @@ -90,10 +92,41 @@ void shouldThrowBudgetExceededExceptionWhenUsageIsAtLeast100Percent() { assertDecision(currentOnly, BudgetState.BLOCK, BudgetThreshold.EXCEEDED); } + @Test + @DisplayName("evaluate는 BudgetStateStore에 비용을 누적하지 않는다") + void shouldNotMutateStoreWhenEvaluatingBudget() { + when(store.getAccumulatedCost(TAGS, USD)) + .thenReturn(usd("10.00")); + + evaluator.evaluate(TAGS, usd("20.00")); + + verify(store, never()).addCost(any(), any()); + } + + @Test + @DisplayName("예산 통화와 비용 통화가 다르면 CURRENCY_MISMATCH를 반환하고 상태를 변경하지 않는다") + void shouldReturnCurrencyMismatchWithoutChangingStateWhenCostCurrencyDiffers() { + when(store.getAccumulatedCost(TAGS, USD)) + .thenReturn(usd("10.00")); + + BudgetDecision decision = evaluator.evaluate(TAGS, krw("50.00")); + + assertThat(decision.state()).isEqualTo(BudgetState.CURRENCY_MISMATCH); + assertThat(decision.threshold()).isEqualTo(BudgetThreshold.NONE); + assertThat(decision.currentUsage()).isEqualTo(usd("10.00")); + assertThat(decision.limit()).isEqualTo(MONTHLY_LIMIT); + + verify(store, never()).addCost(any(), any()); + } + 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); + } + private static void assertDecision( BudgetDecision decision, BudgetState state, diff --git a/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStoreTest.java b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStoreTest.java new file mode 100644 index 0000000..8cf75e8 --- /dev/null +++ b/token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStoreTest.java @@ -0,0 +1,33 @@ +package io.tokenpilot.budget.internal; + +import io.tokenpilot.core.domain.Cost; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Currency; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class InMemoryBudgetStateStoreTest { + + private static final Currency USD = Currency.getInstance("USD"); + private static final Currency KRW = Currency.getInstance("KRW"); + + @Test + @DisplayName("같은 태그라도 통화가 다르면 누적 비용을 분리해야 한다") + void shouldSeparateAccumulatedCostByCurrency() { + InMemoryBudgetStateStore store = new InMemoryBudgetStateStore(); + Map tags = Map.of("tenant_id", "test"); + + store.addCost(tags, usd("10.00")); + + assertThat(store.getAccumulatedCost(tags, USD)).isEqualTo(usd("10.00")); + assertThat(store.getAccumulatedCost(tags, KRW)).isEqualTo(Cost.zero(KRW)); + } + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } +} \ No newline at end of file From 0c2522a7faacc2b0f2f7a93f0c040b0e8c4ea7ae Mon Sep 17 00:00:00 2001 From: rigu1 Date: Fri, 24 Jul 2026 19:04:05 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test(core):=201K=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=9D=98=20=EC=A0=95=ED=99=95=ED=95=9C=20decimal=20shift=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/DefaultCostCalculatorTest.java | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java index 17297f3..baf5bff 100644 --- a/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java +++ b/token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java @@ -64,8 +64,8 @@ TokenType.REASONING, new BigDecimal("0.05") } @Test - @DisplayName("1,000 token은 1K 단가 그대로 계산되어야 한다") - void shouldUseRateAsCostForOneThousandTokens() { + @DisplayName("1 token 비용은 1K 단가를 decimal shift로 정확히 계산해야 한다") + void shouldCalculateOneTokenCostWithDecimalShift() { PricingPlan plan = new PricingPlan( "tiny-model", new BigDecimal("0.0004"), @@ -77,4 +77,19 @@ void shouldUseRateAsCostForOneThousandTokens() { assertThat(cost.value()).isEqualByComparingTo("0.0000004"); } + + @Test + @DisplayName("1,000 token 비용은 1K 단가 그대로 계산되어야 한다") + void shouldUseRateAsCostForOneThousandTokens() { + PricingPlan plan = new PricingPlan( + "tiny-model", + new BigDecimal("0.0004"), + BigDecimal.ZERO + ); + TokenUsage usage = TokenUsage.from(1000, 0); + + Cost cost = calculator.calculate(usage, plan); + + assertThat(cost.value()).isEqualByComparingTo("0.0004"); + } } From 968f2c6e708fc3c60f4af20d7d47768190db7cf1 Mon Sep 17 00:00:00 2001 From: rigu1 Date: Fri, 24 Jul 2026 19:11:31 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix(sample):=20=EC=98=88=EC=82=B0=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=EB=8F=84=20=EB=B9=84=EC=9A=A9=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EA=B2=BD=EA=B3=84=20=EB=B0=98=EC=98=AC?= =?UTF-8?q?=EB=A6=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tokenpilot/sample/SampleController.java | 4 +-- .../SampleApplicationBudgetE2ETest.java | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java b/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java index 242bc72..21f3ba2 100644 --- a/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java +++ b/token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java @@ -103,8 +103,8 @@ public Map budget() { "enabled", "true", "initialState", initialDecision.state().name(), "blockedState", blockedDecision.state().name(), - "currentUsage", blockedDecision.currentUsage().value().toPlainString(), - "limit", blockedDecision.limit().value().toPlainString() + "currentUsage", CostBoundaryFormatter.format(blockedDecision.currentUsage()), + "limit", CostBoundaryFormatter.format(blockedDecision.limit()) ); } } diff --git a/token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationBudgetE2ETest.java b/token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationBudgetE2ETest.java index 0caa74a..5ebc1f2 100644 --- a/token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationBudgetE2ETest.java +++ b/token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationBudgetE2ETest.java @@ -1,8 +1,10 @@ package io.tokenpilot.sample; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.annotation.DirtiesContext; import java.io.IOException; import java.net.URI; @@ -21,6 +23,7 @@ "management.endpoints.web.exposure.include=prometheus,health" } ) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) class SampleApplicationBudgetE2ETest { private final HttpClient httpClient = HttpClient.newHttpClient(); @@ -29,21 +32,37 @@ class SampleApplicationBudgetE2ETest { private int port; @Test - void budgetBeansAndBudgetBlockWorkEndToEnd() throws Exception { + @DisplayName("Budget가 활성화되면 BudgetEvaluator와 BudgetStateStore 빈이 등록되어야 한다") + void shouldRegisterBudgetBeansWhenBudgetIsEnabled() throws Exception { HttpResponse beans = get("/test/token-pilot/beans"); + assertThat(beans.statusCode()).isEqualTo(200); assertThat(beans.body()) .contains("\"budgetEvaluator\":true") .contains("\"budgetStateStore\":true"); + } + @Test + @DisplayName("예산 한도를 초과하면 Budget endpoint가 BLOCK 상태를 반환해야 한다") + void shouldReturnBlockWhenBudgetLimitIsExceeded() throws Exception { HttpResponse budget = get("/test/token-pilot/budget"); + assertThat(budget.statusCode()).isEqualTo(200); assertThat(budget.body()) .contains("\"enabled\":\"true\"") .contains("\"initialState\":\"ALLOW\"") - .contains("\"blockedState\":\"BLOCK\"") - .contains("\"currentUsage\":\"0.0055\"") - .contains("\"limit\":\"0.005\""); + .contains("\"blockedState\":\"BLOCK\""); + } + + @Test + @DisplayName("Budget endpoint의 금액 응답은 표시 경계 반올림을 적용해야 한다") + void shouldFormatBudgetAmountsWithBoundaryRounding() throws Exception { + HttpResponse budget = get("/test/token-pilot/budget"); + + assertThat(budget.statusCode()).isEqualTo(200); + assertThat(budget.body()) + .contains("\"currentUsage\":\"0.005500\"") + .contains("\"limit\":\"0.005000\""); } private HttpResponse get(String path) throws IOException, InterruptedException {