From 48a006a0614cfc35c763da574f4df8791e49c08b Mon Sep 17 00:00:00 2001 From: Leejaewang03 <192717122+Leejaewang03@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:21:37 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=98=88=EC=82=B0=20BLOCK=EC=9D=84=20provi?= =?UTF-8?q?der=20=ED=98=B8=EC=B6=9C=20=EC=A0=84=EC=97=90=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 12 +- .../TokenPilotAutoConfigurationTest.java | 4 +- .../io/tokenpilot/budget/BudgetDecision.java | 73 +++++++- .../io/tokenpilot/budget/BudgetEvaluator.java | 22 ++- .../exception/BudgetExceededException.java | 17 +- .../internal/DefaultBudgetEvaluator.java | 102 ++++++++--- .../internal/DefaultBudgetEvaluatorTest.java | 77 ++++++--- .../notification/BudgetNotificationEvent.java | 2 +- .../BudgetNotificationService.java | 2 +- .../BudgetNotificationServiceTest.java | 5 + .../tokenpilot/sample/SampleController.java | 20 +-- .../SampleApplicationBudgetE2ETest.java | 2 +- .../internal/DefaultLedgerAdvisor.java | 18 ++ .../internal/DefaultLedgerAdvisorTest.java | 160 +++++++++++++++++- 14 files changed, 423 insertions(+), 93 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c63e8e1..ff08848 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ Token Pilot is evolving from a Spring AI usage-tracking starter into a framework-independent Java LLM control and accounting core with optional framework and observability adapters. -Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, basic non-atomic budget evaluation, Spring AI integration, and starter autoconfiguration are implemented. Preflight estimation, context admission, atomic reservation, and estimate/actual reconciliation are 30-day MVP targets, not current capabilities. +Current truth: post-call usage normalization, cost calculation, ledger events, Micrometer publishing, Clock-based monthly budget windows, pure budget decisions, legacy provider-boundary BLOCK enforcement, Spring AI integration, and starter autoconfiguration are implemented. Candidate-aware preflight admission, context admission, atomic reservation, and estimate/actual reconciliation are 30-day MVP targets, not current capabilities. Distribution direction: publish a framework-independent core and an optional Spring AI convenience starter from the same repository and release train. The existing starter artifact is `token-pilot-starter`; `token-pilot-spring-ai-starter` is only a target name until a compatibility ADR and module change land. @@ -74,7 +74,7 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac | `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager | | `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, response usage recording | | `token-pilot-micrometer` | Basic implementation complete | `MetricsOptions`, tag whitelist, and metric metadata exist; metric ownership must be narrowed | -| `token-pilot-budget` | Basic non-atomic implementation | Typed monthly keys and Clock/ZoneId windows implemented; needs BLOCK enforcement, reservation, idempotency, and reconciliation | +| `token-pilot-budget` | Basic non-atomic implementation | Typed monthly keys, Clock/ZoneId windows, pure status/admission decisions, and legacy BLOCK enforcement implemented; needs candidate estimation, reservation, idempotency, and reconciliation | | `token-pilot-notification` | Basic implementation complete | Event API and deduplication exist; not yet connected to the full advisor/budget lifecycle | | `token-pilot-autoconfigure` | Basic implementation complete | Bean registration, property binding, pricing/budget/notification wiring, and `ChatClientBuilderCustomizer` implemented | | `token-pilot-starter` | Basic implementation complete | Thin final user entrypoint that brings runtime modules together | @@ -328,7 +328,7 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works - Budget money interfaces now use `Cost` while preserving `BudgetKey`, `BudgetPolicy`, Clock/ZoneId monthly windows, and per-key policy snapshots. - Until the typed missing-pricing policy lands, `DefaultLedgerManager` preserves the legacy fail-open result as an explicit zero USD `Cost`; do not confuse that compatibility behavior with a priced zero-rate plan. - Spring AI usage extraction converts map/JSON-compatible native usage objects into the normalized core model. Real-provider compatibility fixtures remain required because provider and Spring AI usage shapes can change independently. -- Current budget flow is check-then-add, is not an atomic reservation, and may not enforce `BLOCK` before provider invocation. +- Current legacy budget flow blocks an already-exhausted status before provider invocation, but it remains check-then-add and is not candidate-aware admission or an atomic reservation. - Current Micrometer `ai.token.*` metrics may duplicate Spring AI Observability; preserve compatibility while deciding default suppression or replacement. - The verified Spring AI 2.0.0 path is synchronous `ChatClient` usage recording with a fake provider. Streaming cancellation and reconciliation remain outside the current compatibility guarantee. - The repository, README, JReleaser configuration, and every published module POM use the MIT License. `verifyPublicationMetadata` guards this release contract and ensures the sample app is not published. @@ -396,6 +396,12 @@ Stage and deploy a Central release: ## Update History +### 2026-07-29 + +- Separated pure budget decisions from provider-boundary enforcement: evaluator BLOCK and currency mismatch outcomes now return structured decisions. +- Distinguished committed usage from candidate-inclusive projected usage and marked candidate-free status results as non-admission evidence. +- Added legacy Spring AI BLOCK enforcement before provider invocation while leaving candidate-aware admission, atomic reservation, and reconciliation to #39, #36, and #37. + ### 2026-07-27 - Fixed the 0.1.0 compatibility baseline to Java 25 bytecode/runtime, Spring Boot 4.1.0, and Spring AI 2.0.0. 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 b689fa9..f1e3760 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 @@ -231,7 +231,7 @@ void shouldUseUserClockForMonthlyBudgetWindow() { assertThat(decision.key().window()).isEqualTo(BudgetWindow.parse("2026-08")); assertThat(decision.threshold()).isEqualTo(BudgetThreshold.HALF); - assertThat(decision.currentUsage().value()).isEqualByComparingTo("50.00"); + assertThat(decision.committedUsage().value()).isEqualByComparingTo("50.00"); }); } @@ -411,10 +411,12 @@ public BudgetDecision evaluate(Map tags) { tags.get("tenant_id"), BudgetWindow.parse("2026-07") ), + BudgetDecision.EvaluationType.STATUS, BudgetState.ALLOW, BudgetThreshold.NONE, "allowed", Cost.zero(Currency.getInstance("USD")), + Cost.zero(Currency.getInstance("USD")), Cost.of(BigDecimal.TEN, Currency.getInstance("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 9dd1aaf..ab113a4 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 @@ -2,21 +2,76 @@ import io.tokenpilot.core.domain.Cost; +import java.util.Objects; + /** - * 예산 평가 결과를 나타내는 객체 + * 부수 효과 없는 예산 평가 결과입니다. + * + * @param key 평가 시점에 확정된 예산 bucket 식별자 + * @param evaluationType 후보 비용 포함 여부 + * @param state ALLOW / WARN / BLOCK / CURRENCY_MISMATCH 상태 + * @param threshold 예상 사용량이 도달한 예산 임계치 + * @param reason 상태 설명 + * @param committedUsage 현재 확정된 누적 사용량 + * @param projectedUsage 후보 비용을 포함한 예상 사용량. 상태 조회이거나 통화가 일치하지 않으면 + * {@code committedUsage}와 같습니다. + * @param limit 통화를 포함한 총 예산 * - * - state: ALLOW / WARN / BLOCK / CURRENCY_MISMATCH 상태 - * - threshold: 현재 도달한 예산 임계치 - * - reason: 상태 설명 - * - currentUsage: 통화를 포함한 현재 사용량 - * - limit: 통화를 포함한 총 예산 - * - key: 평가 시점에 확정된 예산 bucket 식별자 + *

Migration note: {@link EvaluationType#STATUS} 결과는 현재 상태 조회용이며 + * provider 호출 허가의 근거가 아닙니다. 기존의 모호한 {@code currentUsage} 대신 확정 + * 사용량은 {@code committedUsage}, 후보 포함 사용량은 {@code projectedUsage}를 사용합니다. */ public record BudgetDecision( BudgetKey key, + EvaluationType evaluationType, BudgetState state, BudgetThreshold threshold, String reason, - Cost currentUsage, + Cost committedUsage, + Cost projectedUsage, Cost limit -) {} +) { + + public BudgetDecision { + Objects.requireNonNull(key, "key must not be null"); + Objects.requireNonNull(evaluationType, "evaluationType must not be null"); + Objects.requireNonNull(state, "state must not be null"); + Objects.requireNonNull(threshold, "threshold must not be null"); + Objects.requireNonNull(reason, "reason must not be null"); + Objects.requireNonNull(committedUsage, "committedUsage must not be null"); + Objects.requireNonNull(projectedUsage, "projectedUsage must not be null"); + Objects.requireNonNull(limit, "limit must not be null"); + + if (!committedUsage.currency().equals(projectedUsage.currency()) + || !committedUsage.currency().equals(limit.currency())) { + throw new IllegalArgumentException( + "committedUsage, projectedUsage and limit must use the same currency" + ); + } + if (evaluationType == EvaluationType.STATUS + && committedUsage.compareTo(projectedUsage) != 0) { + throw new IllegalArgumentException( + "STATUS decisions must not include candidate cost" + ); + } + } + + /** + * 후보 비용을 포함해 provider admission을 판단한 결과인지 반환합니다. + */ + public boolean isAdmissionDecision() { + return evaluationType == EvaluationType.ADMISSION; + } + + public enum EvaluationType { + /** + * 후보 비용 없는 조회 전용 결과입니다. Provider 호출 허가로 사용할 수 없습니다. + */ + STATUS, + + /** + * 후보 비용을 포함한 admission 판단 결과입니다. + */ + ADMISSION + } +} 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 cb85177..76300b5 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 @@ -6,24 +6,32 @@ /** - * AI 호출 전 예산 초과 여부를 판단하는 인터페이스입니다. + * 부수 효과 없이 예산 상태를 판단하는 인터페이스입니다. *

- * 구현체는 현재까지 누적된 비용과 - * 이번 호출로 발생할 비용을 기준으로 - * 호출을 허용하거나 차단하는 역할을 합니다. + * 구현체는 판단 결과를 구조화된 {@link BudgetDecision}으로 반환하며 provider 호출을 직접 + * 차단하거나 알림/metric listener를 호출하지 않습니다. Provider 경계는 반환된 decision을 + * 별도로 집행해야 합니다. */ public interface BudgetEvaluator { /** - * 단순히 현재의 누적 비용이 예산 한도를 초과했는지만 판단합니다. (부수 효과 없음) + * 현재 확정 사용량만 조회합니다. + * + * @return {@link BudgetDecision.EvaluationType#STATUS}인 조회 전용 결과. 후보 비용이 없으므로 + * provider 호출 허가의 근거로 사용할 수 없습니다. */ BudgetDecision evaluate(Map tags); /** - * 이번 호출로 발생할 예상 비용을 포함하여 예산 초과 여부를 판단합니다. (부수 효과 없음) + * 후보 요청의 통화가 포함된 안전 상한 비용을 더해 admission 상태를 판단합니다. + *

+ * {@code projectedUsage >= limit}이면 BLOCK입니다. BLOCK과 CURRENCY_MISMATCH도 예외를 + * 던지지 않고 decision으로 반환합니다. + * + * @return {@link BudgetDecision.EvaluationType#ADMISSION}인 판단 결과 */ BudgetDecision evaluate( Map tags, - Cost cost + Cost candidateCost ); } diff --git a/token-pilot-budget/src/main/java/io/tokenpilot/budget/exception/BudgetExceededException.java b/token-pilot-budget/src/main/java/io/tokenpilot/budget/exception/BudgetExceededException.java index a25c0fa..4ca05f2 100644 --- a/token-pilot-budget/src/main/java/io/tokenpilot/budget/exception/BudgetExceededException.java +++ b/token-pilot-budget/src/main/java/io/tokenpilot/budget/exception/BudgetExceededException.java @@ -2,23 +2,20 @@ import io.tokenpilot.budget.BudgetDecision; +import java.util.Objects; + /** - * BudgetExceededException은 "LLM 호출을 멈추기 위해" 던지는 예외이다. - * - * 이 예외가 던져지는 순간: - * - Spring AI 호출 체인이 중단된다 - * - 실제 LLM API 요청은 나가지 않는다 - * - * 즉, 이것이 바로 비용 기반 Circuit Breaker 역할을 한다. + * Provider 경계에서 BLOCK decision을 집행할 때 던지는 예외입니다. + *

+ * {@code BudgetEvaluator}는 이 예외를 직접 던지지 않습니다. Provider invocation 직전의 + * 경계가 원래 decision을 보존한 이 예외를 던져 호출 체인을 중단합니다. */ public class BudgetExceededException extends RuntimeException { - // 어떤 판단으로 차단되었는지 담고 있음 private final BudgetDecision decision; public BudgetExceededException(BudgetDecision decision) { - // Exception 메시지로 reason을 사용 - super(decision.reason()); + super(Objects.requireNonNull(decision, "decision must not be null").reason()); this.decision = decision; } 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 b87988d..370f49a 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 @@ -1,6 +1,7 @@ package io.tokenpilot.budget.internal; import io.tokenpilot.budget.BudgetDecision; +import io.tokenpilot.budget.BudgetDecision.EvaluationType; import io.tokenpilot.budget.BudgetEvaluator; import io.tokenpilot.budget.BudgetKey; import io.tokenpilot.budget.BudgetPolicy; @@ -8,7 +9,6 @@ import io.tokenpilot.budget.BudgetStateStore; import io.tokenpilot.budget.BudgetThreshold; import io.tokenpilot.budget.BudgetWindow; -import io.tokenpilot.budget.exception.BudgetExceededException; import io.tokenpilot.core.domain.Cost; import java.math.BigDecimal; @@ -16,6 +16,11 @@ import java.util.Map; import java.util.Objects; +/** + * 현재 구현이 지원하는 snapshot은 확정 비용과 후보 비용입니다. + * {@code projectedUsage = committedUsage + candidateCost}이며 active reservation과 + * reconciliation liability는 #36, #37에서 추가됩니다. + */ public class DefaultBudgetEvaluator implements BudgetEvaluator { private final BudgetStateStore store; @@ -29,33 +34,41 @@ public DefaultBudgetEvaluator(BudgetStateStore store, BudgetPolicy policy, Clock } @Override - public BudgetDecision evaluate(Map tags, Cost cost) { - Objects.requireNonNull(cost, "cost must not be null"); + public BudgetDecision evaluate(Map tags, Cost candidateCost) { + Objects.requireNonNull(candidateCost, "candidateCost must not be null"); BudgetKey key = resolveKey(tags); - Cost currentUsage = store.getAccumulatedCost(key, policy.monthlyLimit()); - if (!policy.monthlyLimit().currency().equals(cost.currency())) { + Cost committedUsage = store.getAccumulatedCost(key, policy.monthlyLimit()); + if (!policy.monthlyLimit().currency().equals(candidateCost.currency())) { return decision( key, + EvaluationType.ADMISSION, BudgetState.CURRENCY_MISMATCH, BudgetThreshold.NONE, "예산 통화와 비용 통화가 일치하지 않습니다", - currentUsage + committedUsage, + committedUsage ); } - Cost usage = currentUsage.add(cost); - BudgetDecision decision = decide(key, usage); - if (decision.state() == BudgetState.BLOCK) { - throw new BudgetExceededException(decision); - } - return decision; + Cost projectedUsage = committedUsage.add(candidateCost); + return decide( + key, + EvaluationType.ADMISSION, + committedUsage, + projectedUsage + ); } @Override public BudgetDecision evaluate(Map tags) { BudgetKey key = resolveKey(tags); - Cost usage = store.getAccumulatedCost(key, policy.monthlyLimit()); - return decide(key, usage); + Cost committedUsage = store.getAccumulatedCost(key, policy.monthlyLimit()); + return decide( + key, + EvaluationType.STATUS, + committedUsage, + committedUsage + ); } private BudgetKey resolveKey(Map tags) { @@ -77,35 +90,76 @@ private BudgetKey resolveKey(Map tags) { ); } - private BudgetDecision decide(BudgetKey key, Cost usage) { + private BudgetDecision decide( + BudgetKey key, + EvaluationType evaluationType, + Cost committedUsage, + Cost projectedUsage + ) { Cost halfThreshold = threshold("0.5"); Cost warningThreshold = threshold("0.8"); - if (usage.compareTo(policy.monthlyLimit()) >= 0) { - return decision(key, BudgetState.BLOCK, BudgetThreshold.EXCEEDED, "월 예산을 초과했습니다", usage); + if (projectedUsage.compareTo(policy.monthlyLimit()) >= 0) { + return decision( + key, + evaluationType, + BudgetState.BLOCK, + BudgetThreshold.EXCEEDED, + "월 예산을 초과했습니다", + committedUsage, + projectedUsage + ); } - if (usage.compareTo(warningThreshold) >= 0) { - return decision(key, BudgetState.WARN, BudgetThreshold.WARNING, "월 예산의 80% 이상 사용", usage); + if (projectedUsage.compareTo(warningThreshold) >= 0) { + return decision( + key, + evaluationType, + BudgetState.WARN, + BudgetThreshold.WARNING, + "월 예산의 80% 이상 사용", + committedUsage, + projectedUsage + ); } - if (usage.compareTo(halfThreshold) >= 0) { - return decision(key, BudgetState.ALLOW, BudgetThreshold.HALF, "월 예산의 50% 이상 사용", usage); + if (projectedUsage.compareTo(halfThreshold) >= 0) { + return decision( + key, + evaluationType, + BudgetState.ALLOW, + BudgetThreshold.HALF, + "월 예산의 50% 이상 사용", + committedUsage, + projectedUsage + ); } - return decision(key, BudgetState.ALLOW, BudgetThreshold.NONE, "정상 범위 사용", usage); + return decision( + key, + evaluationType, + BudgetState.ALLOW, + BudgetThreshold.NONE, + "정상 범위 사용", + committedUsage, + projectedUsage + ); } private BudgetDecision decision( BudgetKey key, + EvaluationType evaluationType, BudgetState state, BudgetThreshold threshold, String reason, - Cost usage + Cost committedUsage, + Cost projectedUsage ) { return new BudgetDecision( key, + evaluationType, state, threshold, reason, - usage, + committedUsage, + projectedUsage, policy.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 d6217b0..8258726 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 @@ -1,13 +1,13 @@ package io.tokenpilot.budget.internal; import io.tokenpilot.budget.BudgetDecision; +import io.tokenpilot.budget.BudgetDecision.EvaluationType; import io.tokenpilot.budget.BudgetKey; import io.tokenpilot.budget.BudgetPolicy; import io.tokenpilot.budget.BudgetState; import io.tokenpilot.budget.BudgetStateStore; import io.tokenpilot.budget.BudgetThreshold; import io.tokenpilot.budget.BudgetWindow; -import io.tokenpilot.budget.exception.BudgetExceededException; import io.tokenpilot.core.domain.Cost; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -49,31 +49,67 @@ void setUp() { store = mock(BudgetStateStore.class); } - @Test - void 예상_비용을_포함해_임계치를_판정한다() { + @ParameterizedTest + @CsvSource({ + "10.00, 20.00, 30.00, ALLOW, NONE", + "70.00, 10.00, 80.00, WARN, WARNING" + }) + void 한도_미만의_예상_비용을_포함해_ALLOW와_WARN을_판정한다( + String committed, + String candidate, + String projected, + BudgetState expectedState, + BudgetThreshold expectedThreshold + ) { DefaultBudgetEvaluator evaluator = evaluator(policy(null, ZoneOffset.UTC), "2026-07-22T00:00:00Z"); - when(store.getAccumulatedCost(any(), any())).thenReturn(usd("70.00")); + when(store.getAccumulatedCost(any(), any())).thenReturn(usd(committed)); - BudgetDecision result = evaluator.evaluate(TAGS, usd("10.00")); + BudgetDecision result = evaluator.evaluate(TAGS, usd(candidate)); - assertThat(result.state()).isEqualTo(BudgetState.WARN); - assertThat(result.threshold()).isEqualTo(BudgetThreshold.WARNING); + assertThat(result.state()).isEqualTo(expectedState); + assertThat(result.threshold()).isEqualTo(expectedThreshold); assertThat(result.key()).isEqualTo(key("policy-a", "tenant-a", "2026-07")); - assertThat(result.currentUsage()).isEqualTo(usd("80.00")); + assertThat(result.evaluationType()).isEqualTo(EvaluationType.ADMISSION); + assertThat(result.committedUsage()).isEqualTo(usd(committed)); + assertThat(result.projectedUsage()).isEqualTo(usd(projected)); + } + + @ParameterizedTest + @CsvSource({ + "90.00, 10.00, 100.00", + "95.00, 10.00, 105.00" + }) + void 예상_사용량이_한도와_같거나_크면_예외_없이_BLOCK을_반환한다( + String committed, + String candidate, + String projected + ) { + DefaultBudgetEvaluator evaluator = evaluator(policy(null, ZoneOffset.UTC), "2026-07-22T00:00:00Z"); + when(store.getAccumulatedCost(any(), any())).thenReturn(usd(committed)); + + BudgetDecision decision = evaluator.evaluate(TAGS, usd(candidate)); + + assertThat(decision.state()).isEqualTo(BudgetState.BLOCK); + assertThat(decision.threshold()).isEqualTo(BudgetThreshold.EXCEEDED); + assertThat(decision.evaluationType()).isEqualTo(EvaluationType.ADMISSION); + assertThat(decision.key()).isEqualTo(key("policy-a", "tenant-a", "2026-07")); + assertThat(decision.committedUsage()).isEqualTo(usd(committed)); + assertThat(decision.projectedUsage()).isEqualTo(usd(projected)); + assertThat(decision.limit()).isEqualTo(usd("100.00")); + verify(store, never()).addCost(any(), any(), any()); } @Test - void 예산_초과시_동일_key를_포함한_예외가_발생한다() { + void 후보_없는_상태_조회는_admission_결과가_아니다() { DefaultBudgetEvaluator evaluator = evaluator(policy(null, ZoneOffset.UTC), "2026-07-22T00:00:00Z"); - when(store.getAccumulatedCost(any(), any())).thenReturn(usd("95.00")); - - assertThatThrownBy(() -> evaluator.evaluate(TAGS, usd("10.00"))) - .isInstanceOf(BudgetExceededException.class) - .extracting(exception -> ((BudgetExceededException) exception).getDecision()) - .satisfies(decision -> { - assertThat(decision.state()).isEqualTo(BudgetState.BLOCK); - assertThat(decision.key()).isEqualTo(key("policy-a", "tenant-a", "2026-07")); - }); + when(store.getAccumulatedCost(any(), any())).thenReturn(usd("40.00")); + + BudgetDecision decision = evaluator.evaluate(TAGS); + + assertThat(decision.evaluationType()).isEqualTo(EvaluationType.STATUS); + assertThat(decision.isAdmissionDecision()).isFalse(); + assertThat(decision.committedUsage()).isEqualTo(usd("40.00")); + assertThat(decision.projectedUsage()).isEqualTo(decision.committedUsage()); } @Test @@ -166,8 +202,11 @@ void setUp() { ); assertThat(decision.state()).isEqualTo(BudgetState.CURRENCY_MISMATCH); - assertThat(decision.currentUsage()).isEqualTo(usd("10.00")); + assertThat(decision.evaluationType()).isEqualTo(EvaluationType.ADMISSION); + assertThat(decision.committedUsage()).isEqualTo(usd("10.00")); + assertThat(decision.projectedUsage()).isEqualTo(decision.committedUsage()); assertThat(decision.limit()).isEqualTo(usd("100.00")); + verify(store, never()).addCost(any(), any(), any()); } private DefaultBudgetEvaluator evaluator(BudgetPolicy policy, String instant) { 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 3fecfb7..bd42865 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 @@ -15,7 +15,7 @@ public record BudgetNotificationEvent( BudgetThreshold threshold, BudgetState state, String reason, - Cost currentUsage, + Cost projectedUsage, Cost limit, Map tags ) {} diff --git a/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationService.java b/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationService.java index 0e1bc7b..43c3ffd 100644 --- a/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationService.java +++ b/token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationService.java @@ -47,7 +47,7 @@ public void notifyIfNeeded( current, decision.state(), decision.reason(), - decision.currentUsage(), + decision.projectedUsage(), decision.limit(), tags ); 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 7e26ff8..3f9e2d0 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 @@ -1,6 +1,7 @@ package io.tokenpilot.notification; import io.tokenpilot.budget.BudgetDecision; +import io.tokenpilot.budget.BudgetDecision.EvaluationType; import io.tokenpilot.budget.BudgetKey; import io.tokenpilot.budget.BudgetState; import io.tokenpilot.budget.BudgetThreshold; @@ -68,6 +69,8 @@ class BudgetNotificationServiceTest { verify(store).getLastNotifiedThreshold(same(key)); verify(store).updateLastNotifiedThreshold(same(key), same(BudgetThreshold.HALF)); assertThat(event.getValue().key()).isSameAs(key); + assertThat(event.getValue().projectedUsage()) + .isEqualTo(Cost.of(new BigDecimal("50"), Currency.getInstance("USD"))); } private static BudgetKey key(String window) { @@ -81,10 +84,12 @@ private static BudgetDecision decision( ) { return new BudgetDecision( key, + EvaluationType.ADMISSION, BudgetState.WARN, threshold, threshold.name(), Cost.of(new BigDecimal(usage), Currency.getInstance("USD")), + Cost.of(new BigDecimal(usage), Currency.getInstance("USD")), Cost.of(new BigDecimal("100"), Currency.getInstance("USD")) ); } 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 18814b4..246aee9 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 @@ -2,8 +2,8 @@ import io.tokenpilot.budget.BudgetDecision; import io.tokenpilot.budget.BudgetEvaluator; +import io.tokenpilot.budget.BudgetState; import io.tokenpilot.budget.BudgetStateStore; -import io.tokenpilot.budget.exception.BudgetExceededException; import io.tokenpilot.core.LedgerManager; import io.tokenpilot.core.domain.Cost; import io.tokenpilot.core.domain.TokenUsage; @@ -96,23 +96,21 @@ public Map budget() { Cost.of(new BigDecimal("0.0045"), initialDecision.limit().currency()) ); - try { - evaluator.evaluate(tags, projectedCost); - return Map.of( - "enabled", "true", - "initialState", initialDecision.state().name(), - "blockedState", "NONE" - ); - } catch (BudgetExceededException exception) { - BudgetDecision blockedDecision = exception.getDecision(); + BudgetDecision blockedDecision = evaluator.evaluate(tags, projectedCost); + if (blockedDecision.state() == BudgetState.BLOCK) { return Map.of( "enabled", "true", "initialState", initialDecision.state().name(), "blockedState", blockedDecision.state().name(), - "currentUsage", CostBoundaryFormatter.format(blockedDecision.currentUsage()), + "projectedUsage", CostBoundaryFormatter.format(blockedDecision.projectedUsage()), "limit", CostBoundaryFormatter.format(blockedDecision.limit()) ); } + return Map.of( + "enabled", "true", + "initialState", initialDecision.state().name(), + "blockedState", "NONE" + ); } } 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 d680cee..ac29f5a 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 @@ -42,7 +42,7 @@ void budgetBeansAndBudgetBlockWorkEndToEnd() throws Exception { .contains("\"enabled\":\"true\"") .contains("\"initialState\":\"ALLOW\"") .contains("\"blockedState\":\"BLOCK\"") - .contains("\"currentUsage\":\"0.005500\"") + .contains("\"projectedUsage\":\"0.005500\"") .contains("\"limit\":\"0.005000\""); } 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 c80a808..016c629 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 @@ -3,6 +3,7 @@ import io.tokenpilot.budget.BudgetDecision; import io.tokenpilot.budget.BudgetEvaluator; import io.tokenpilot.budget.BudgetStateStore; +import io.tokenpilot.budget.exception.BudgetExceededException; import io.tokenpilot.core.*; import io.tokenpilot.core.domain.*; import io.tokenpilot.springai.LedgerAdvisor; @@ -21,6 +22,10 @@ * 그 결과를 {@link LedgerManager}에 기록하는 핵심 비즈니스 로직을 수행합니다. * 또한 {@link BudgetEvaluator}를 통해 예산 초과 여부를 사전에 차단하고, * 호출 성공 시 {@link BudgetStateStore}에 비용을 누적합니다. + *

+ * 현재 {@link #before(ChatClientRequest, AdvisorChain)}의 상태 조회는 이미 BLOCK인 예산의 + * provider 호출 회귀를 막는 legacy guard입니다. 후보 비용을 포함한 admission 근거가 아니며, + * 최종 candidate-aware lifecycle은 #39의 {@code adviseCall()} 경계가 담당합니다. */ public class DefaultLedgerAdvisor implements LedgerAdvisor { @@ -53,6 +58,7 @@ public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) { if (budgetEvaluator != null) { Map tags = extractTagsFromRequest(request); BudgetDecision decision = budgetEvaluator.evaluate(tags); + enforceExistingBlock(decision); return request.mutate() .context(BUDGET_DECISION_CONTEXT, decision) .build(); @@ -86,6 +92,18 @@ public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) return response; } + private void enforceExistingBlock(BudgetDecision decision) { + switch (decision.state()) { + case ALLOW, WARN -> { + // Legacy boundary: candidate-aware admission is implemented by issue #39. + } + case BLOCK -> throw new BudgetExceededException(decision); + case CURRENCY_MISMATCH -> throw new IllegalStateException( + "Budget decision currency mismatch: " + decision.reason() + ); + } + } + private String extractModelId(ChatClientResponse response) { if (response.chatResponse() != null && response.chatResponse().getMetadata() != null) { String model = response.chatResponse().getMetadata().getModel(); 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 b30eb2e..1ff0851 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 @@ -1,21 +1,26 @@ package io.tokenpilot.springai.internal; import io.tokenpilot.budget.BudgetDecision; +import io.tokenpilot.budget.BudgetDecision.EvaluationType; import io.tokenpilot.budget.BudgetEvaluator; import io.tokenpilot.budget.BudgetKey; import io.tokenpilot.budget.BudgetState; import io.tokenpilot.budget.BudgetStateStore; import io.tokenpilot.budget.BudgetThreshold; import io.tokenpilot.budget.BudgetWindow; +import io.tokenpilot.budget.exception.BudgetExceededException; import io.tokenpilot.core.*; import io.tokenpilot.core.domain.*; import io.tokenpilot.springai.LedgerAdvisor; import io.tokenpilot.springai.UsageExtractor; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.ai.chat.client.ChatClientRequest; import org.springframework.ai.chat.client.ChatClientResponse; import org.springframework.ai.chat.client.advisor.api.AdvisorChain; +import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain; import org.springframework.ai.chat.metadata.ChatResponseMetadata; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.model.Generation; @@ -28,6 +33,7 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -107,7 +113,7 @@ void recordBudgetAfterAIResponse() { @DisplayName("AI 호출 전 BudgetEvaluator를 통해 예산을 체크해야 한다") void checkBudgetBeforeAIRequest() { BudgetEvaluator budgetEvaluator = mock(BudgetEvaluator.class); - BudgetDecision decision = decision(); + BudgetDecision decision = decision(BudgetState.ALLOW); when(budgetEvaluator.evaluate(anyMap())).thenReturn(decision); DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor(mock(LedgerManager.class), mock(UsageExtractor.class), budgetEvaluator, null, null, null); @@ -126,6 +132,120 @@ void checkBudgetBeforeAIRequest() { .isSameAs(decision); } + @Test + @DisplayName("BLOCK이면 provider 호출 전 예외로 중단하고 ledger와 budget을 변경하지 않아야 한다") + void blockBeforeProviderInvocation() { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + BudgetEvaluator budgetEvaluator = mock(BudgetEvaluator.class); + BudgetStateStore budgetStateStore = mock(BudgetStateStore.class); + CostCalculator costCalculator = mock(CostCalculator.class); + PricingRegistry pricingRegistry = mock(PricingRegistry.class); + CallAdvisorChain providerChain = mock(CallAdvisorChain.class); + BudgetDecision blockDecision = decision(BudgetState.BLOCK); + when(budgetEvaluator.evaluate(anyMap())).thenReturn(blockDecision); + + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + budgetEvaluator, + budgetStateStore, + costCalculator, + pricingRegistry + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of("tenant_id", "tenant-abc") + ); + + assertThatThrownBy(() -> advisor.adviseCall(request, providerChain)) + .isInstanceOf(BudgetExceededException.class) + .satisfies(throwable -> { + BudgetDecision preserved = + ((BudgetExceededException) throwable).getDecision(); + assertThat(preserved).isSameAs(blockDecision); + assertThat(preserved.reason()).isEqualTo("blocked"); + assertThat(preserved.projectedUsage()).isEqualTo(usd("100.00")); + assertThat(preserved.limit()).isEqualTo(usd("100.00")); + assertThat(preserved.limit().currency()).isEqualTo(USD); + }); + + verify(budgetEvaluator).evaluate( + argThat(tags -> tags.get("tenant_id").equals("tenant-abc")) + ); + verifyNoInteractions( + providerChain, + ledgerManager, + extractor, + budgetStateStore, + costCalculator, + pricingRegistry + ); + } + + @ParameterizedTest + @EnumSource(value = BudgetState.class, names = {"ALLOW", "WARN"}) + @DisplayName("ALLOW와 WARN만 provider chain으로 진행해야 한다") + void allowAndWarnProceedToProvider(BudgetState state) { + LedgerManager ledgerManager = mock(LedgerManager.class); + UsageExtractor extractor = mock(UsageExtractor.class); + BudgetEvaluator budgetEvaluator = mock(BudgetEvaluator.class); + CallAdvisorChain providerChain = mock(CallAdvisorChain.class); + BudgetDecision decision = decision(state); + TokenUsage usage = TokenUsage.from(0, 0); + when(budgetEvaluator.evaluate(anyMap())).thenReturn(decision); + when(extractor.extract(any())).thenReturn(usage); + when(providerChain.nextCall(any())).thenAnswer(invocation -> { + ChatClientRequest resolvedRequest = invocation.getArgument(0); + return new ChatClientResponse(null, resolvedRequest.context()); + }); + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + ledgerManager, + extractor, + budgetEvaluator, + null, + null, + null + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of("tenant_id", "tenant-abc") + ); + + ChatClientResponse response = advisor.adviseCall(request, providerChain); + + verify(providerChain).nextCall(any(ChatClientRequest.class)); + verify(ledgerManager).record("unknown-model", usage, Map.of("tenant_id", "tenant-abc")); + assertThat(response.context().get(DefaultLedgerAdvisor.BUDGET_DECISION_CONTEXT)) + .isSameAs(decision); + } + + @Test + @DisplayName("CURRENCY_MISMATCH는 fail-closed하고 provider chain으로 진행하지 않아야 한다") + void currencyMismatchDoesNotProceedToProvider() { + BudgetEvaluator budgetEvaluator = mock(BudgetEvaluator.class); + CallAdvisorChain providerChain = mock(CallAdvisorChain.class); + when(budgetEvaluator.evaluate(anyMap())) + .thenReturn(decision(BudgetState.CURRENCY_MISMATCH)); + DefaultLedgerAdvisor advisor = new DefaultLedgerAdvisor( + mock(LedgerManager.class), + mock(UsageExtractor.class), + budgetEvaluator, + null, + null, + null + ); + ChatClientRequest request = new ChatClientRequest( + new Prompt("test"), + Map.of("tenant_id", "tenant-abc") + ); + + assertThatThrownBy(() -> advisor.adviseCall(request, providerChain)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("currency mismatch"); + verifyNoInteractions(providerChain); + } + @Test @DisplayName("Advisor 이름과 순서가 기본값으로 설정되어야 한다") void checkAdvisorMetadata() { @@ -135,7 +255,29 @@ void checkAdvisorMetadata() { assertThat(advisor.getOrder()).isEqualTo(0); } + private static final Currency USD = Currency.getInstance("USD"); + private static BudgetDecision decision() { + return decision(BudgetState.ALLOW); + } + + private static BudgetDecision decision(BudgetState state) { + Cost usage = switch (state) { + case ALLOW -> usd("0"); + case WARN -> usd("80.00"); + case BLOCK, CURRENCY_MISMATCH -> usd("100.00"); + }; + BudgetThreshold threshold = switch (state) { + case ALLOW, CURRENCY_MISMATCH -> BudgetThreshold.NONE; + case WARN -> BudgetThreshold.WARNING; + case BLOCK -> BudgetThreshold.EXCEEDED; + }; + String reason = switch (state) { + case ALLOW -> "allowed"; + case WARN -> "warning"; + case BLOCK -> "blocked"; + case CURRENCY_MISMATCH -> "currency mismatch"; + }; return new BudgetDecision( new BudgetKey( "policy-a", @@ -143,11 +285,17 @@ private static BudgetDecision decision() { "tenant-abc", BudgetWindow.parse("2026-07") ), - BudgetState.ALLOW, - BudgetThreshold.NONE, - "allowed", - Cost.zero(Currency.getInstance("USD")), - Cost.of(new BigDecimal("100.00"), Currency.getInstance("USD")) + EvaluationType.STATUS, + state, + threshold, + reason, + usage, + usage, + usd("100.00") ); } + + private static Cost usd(String amount) { + return Cost.of(new BigDecimal(amount), USD); + } } From 2618eae87533e4bcbf9681d40275e6c55cde455b Mon Sep 17 00:00:00 2001 From: Leejaewang03 <192717122+Leejaewang03@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:26:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?AI=20=EB=A6=AC=EB=B7=B0=20=EC=A7=80?= =?UTF-8?q?=EC=A0=81=20=EC=82=AC=ED=95=AD=20=EB=B3=B4=EC=99=84=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +- .../internal/DefaultBudgetEvaluatorTest.java | 1 + .../tokenpilot/sample/SampleController.java | 3 +- .../SampleApplicationBudgetE2ETest.java | 87 +++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff08848..a2fa71d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,9 +72,9 @@ Token Pilot의 제품 포지션은 framework-independent Java LLM control and ac | Module | Status | Notes | | --- | --- | --- | | `token-pilot-core` | Basic implementation complete | Domain records, pricing, calculator, registry, ledger manager | -| `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, response usage recording | +| `token-pilot-spring-ai` | Basic implementation complete | Spring AI 2.0.0 `UsageExtractor`, `LedgerAdvisor`, response usage recording, and legacy provider-boundary BLOCK enforcement | | `token-pilot-micrometer` | Basic implementation complete | `MetricsOptions`, tag whitelist, and metric metadata exist; metric ownership must be narrowed | -| `token-pilot-budget` | Basic non-atomic implementation | Typed monthly keys, Clock/ZoneId windows, pure status/admission decisions, and legacy BLOCK enforcement implemented; needs candidate estimation, reservation, idempotency, and reconciliation | +| `token-pilot-budget` | Basic non-atomic implementation | Typed monthly keys, Clock/ZoneId windows, and pure status/admission decisions implemented; needs candidate estimation, reservation, idempotency, and reconciliation | | `token-pilot-notification` | Basic implementation complete | Event API and deduplication exist; not yet connected to the full advisor/budget lifecycle | | `token-pilot-autoconfigure` | Basic implementation complete | Bean registration, property binding, pricing/budget/notification wiring, and `ChatClientBuilderCustomizer` implemented | | `token-pilot-starter` | Basic implementation complete | Thin final user entrypoint that brings runtime modules together | @@ -328,7 +328,7 @@ The active checklist is in `docs/30_DAY_MVP_REPORT.md`; detailed long-term works - Budget money interfaces now use `Cost` while preserving `BudgetKey`, `BudgetPolicy`, Clock/ZoneId monthly windows, and per-key policy snapshots. - Until the typed missing-pricing policy lands, `DefaultLedgerManager` preserves the legacy fail-open result as an explicit zero USD `Cost`; do not confuse that compatibility behavior with a priced zero-rate plan. - Spring AI usage extraction converts map/JSON-compatible native usage objects into the normalized core model. Real-provider compatibility fixtures remain required because provider and Spring AI usage shapes can change independently. -- Current legacy budget flow blocks an already-exhausted status before provider invocation, but it remains check-then-add and is not candidate-aware admission or an atomic reservation. +- The legacy provider boundary blocks an already-exhausted budget decision before provider invocation. Its candidate-free `STATUS` input is a regression guard, not admission evidence; the flow remains check-then-add and is not an atomic reservation. - Current Micrometer `ai.token.*` metrics may duplicate Spring AI Observability; preserve compatibility while deciding default suppression or replacement. - The verified Spring AI 2.0.0 path is synchronous `ChatClient` usage recording with a fake provider. Streaming cancellation and reconciliation remain outside the current compatibility guarantee. - The repository, README, JReleaser configuration, and every published module POM use the MIT License. `verifyPublicationMetadata` guards this release contract and ensures the sample app is not published. 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 8258726..5c4a17b 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 @@ -72,6 +72,7 @@ void setUp() { assertThat(result.evaluationType()).isEqualTo(EvaluationType.ADMISSION); assertThat(result.committedUsage()).isEqualTo(usd(committed)); assertThat(result.projectedUsage()).isEqualTo(usd(projected)); + verify(store, never()).addCost(any(), any(), any()); } @ParameterizedTest 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 246aee9..1065052 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 @@ -97,7 +97,8 @@ public Map budget() { ); BudgetDecision blockedDecision = evaluator.evaluate(tags, projectedCost); - if (blockedDecision.state() == BudgetState.BLOCK) { + if (blockedDecision.state() == BudgetState.BLOCK + || blockedDecision.state() == BudgetState.CURRENCY_MISMATCH) { return Map.of( "enabled", "true", "initialState", initialDecision.state().name(), 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 ac29f5a..b77fa56 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,16 +1,35 @@ package io.tokenpilot.sample; +import io.tokenpilot.budget.BudgetDecision; +import io.tokenpilot.budget.BudgetDecision.EvaluationType; +import io.tokenpilot.budget.BudgetEvaluator; +import io.tokenpilot.budget.BudgetKey; +import io.tokenpilot.budget.BudgetState; +import io.tokenpilot.budget.BudgetStateStore; +import io.tokenpilot.budget.BudgetThreshold; +import io.tokenpilot.budget.BudgetWindow; +import io.tokenpilot.core.LedgerManager; +import io.tokenpilot.core.domain.Cost; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; import java.io.IOException; +import java.math.BigDecimal; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.Currency; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @SpringBootTest( webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, @@ -46,6 +65,74 @@ void budgetBeansAndBudgetBlockWorkEndToEnd() throws Exception { .contains("\"limit\":\"0.005000\""); } + @Test + @SuppressWarnings("unchecked") + void currencyMismatchIsReportedAsBlockedState() { + BudgetEvaluator evaluator = mock(BudgetEvaluator.class); + BudgetStateStore stateStore = mock(BudgetStateStore.class); + ObjectProvider evaluatorProvider = mock(ObjectProvider.class); + ObjectProvider stateStoreProvider = mock(ObjectProvider.class); + when(evaluatorProvider.getIfAvailable()).thenReturn(evaluator); + when(stateStoreProvider.getIfAvailable()).thenReturn(stateStore); + when(evaluator.evaluate(anyMap())).thenReturn(decision( + EvaluationType.STATUS, + BudgetState.ALLOW, + Cost.zero(Currency.getInstance("USD")) + )); + when(evaluator.evaluate(anyMap(), any(Cost.class))) + .thenReturn( + decision( + EvaluationType.ADMISSION, + BudgetState.ALLOW, + Cost.of(new BigDecimal("0.001"), Currency.getInstance("USD")) + ), + decision( + EvaluationType.ADMISSION, + BudgetState.CURRENCY_MISMATCH, + Cost.of(new BigDecimal("0.0045"), Currency.getInstance("USD")) + ) + ); + SampleController controller = new SampleController( + mock(ApplicationContext.class), + mock(LedgerManager.class), + evaluatorProvider, + stateStoreProvider + ); + + Map response = controller.budget(); + + assertThat(response) + .containsEntry("blockedState", "CURRENCY_MISMATCH") + .containsEntry("projectedUsage", "0.004500") + .containsEntry("limit", "0.005000"); + } + + private static BudgetDecision decision( + EvaluationType evaluationType, + BudgetState state, + Cost projectedUsage + ) { + Cost committedUsage = evaluationType == EvaluationType.STATUS + || state == BudgetState.CURRENCY_MISMATCH + ? projectedUsage + : Cost.zero(projectedUsage.currency()); + return new BudgetDecision( + new BudgetKey( + "policy-a", + "tenant", + "budget-sample-tenant", + BudgetWindow.parse("2026-07") + ), + evaluationType, + state, + BudgetThreshold.NONE, + state.name(), + committedUsage, + projectedUsage, + Cost.of(new BigDecimal("0.005"), projectedUsage.currency()) + ); + } + private HttpResponse get(String path) throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create("http://localhost:" + port + path)) From f23130595170b0ff06991298edbc986cceee4016 Mon Sep 17 00:00:00 2001 From: Leejaewang03 <192717122+Leejaewang03@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:56:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20currentUsage=20=ED=98=B8=ED=99=98=20accessor=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +++ .../notification/BudgetNotificationEvent.java | 27 +++++++++++++++++-- .../BudgetNotificationServiceTest.java | 3 +++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a2fa71d..fbf832e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -396,6 +396,10 @@ Stage and deploy a Central release: ## Update History +### 2026-08-04 + +- Preserved the deprecated `BudgetNotificationEvent.currentUsage()` compatibility accessor through 0.1.x while migrating handlers to `projectedUsage()`; removal is planned for 0.2.0. + ### 2026-07-29 - Separated pure budget decisions from provider-boundary enforcement: evaluator BLOCK and currency mismatch outcomes now return structured decisions. 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 bd42865..7a791aa 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 @@ -8,7 +8,20 @@ import java.util.Map; /** - * 예산 임계치 도달 시 발생하는 알림 이벤트 + * 예산 임계치 도달 시 발생하는 알림 이벤트입니다. + * + * @param key 예산 bucket 식별자 + * @param threshold 도달한 임계치 + * @param state 예산 상태 + * @param reason 상태 설명 + * @param projectedUsage 후보 비용을 포함한 예상 사용량 + * @param limit 예산 한도 + * @param tags 알림에 전달할 태그 + * + *

Migration note: 후보 비용 포함 사용량의 의미를 명확히 하기 위해 + * record component 이름을 {@code currentUsage}에서 {@code projectedUsage}로 변경했습니다. + * 기존 handler를 위한 {@link #currentUsage()} 호환 accessor는 0.1.x 동안 유지하며 + * 0.2.0에서 제거할 예정입니다. */ public record BudgetNotificationEvent( BudgetKey key, @@ -18,4 +31,14 @@ public record BudgetNotificationEvent( Cost projectedUsage, Cost limit, Map tags -) {} +) { + + /** + * @return {@link #projectedUsage()}와 동일한 후보 비용 포함 예상 사용량 + * @deprecated 후보 비용 포함 사용량은 {@link #projectedUsage()}를 사용하세요. + */ + @Deprecated(since = "0.1.0", forRemoval = true) + public Cost currentUsage() { + return projectedUsage; + } +} 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 3f9e2d0..6bc580e 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 @@ -55,6 +55,7 @@ class BudgetNotificationServiceTest { } @Test + @SuppressWarnings("removal") void event와_notification_store가_decision의_동일한_key를_사용한다() { BudgetNotificationHandler handler = mock(BudgetNotificationHandler.class); NotificationStateStore store = mock(NotificationStateStore.class); @@ -71,6 +72,8 @@ class BudgetNotificationServiceTest { assertThat(event.getValue().key()).isSameAs(key); assertThat(event.getValue().projectedUsage()) .isEqualTo(Cost.of(new BigDecimal("50"), Currency.getInstance("USD"))); + assertThat(event.getValue().currentUsage()) + .isEqualTo(event.getValue().projectedUsage()); } private static BudgetKey key(String window) {