Skip to content

[Core] BudgetDecision.BLOCK이 provider 호출을 차단하도록 수정 - #54

Merged
HuitaePark merged 3 commits into
mainfrom
fix/issue-25-budget-block-enforcement
Aug 3, 2026
Merged

[Core] BudgetDecision.BLOCK이 provider 호출을 차단하도록 수정#54
HuitaePark merged 3 commits into
mainfrom
fix/issue-25-budget-block-enforcement

Conversation

@Leejaewang03

@Leejaewang03 Leejaewang03 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

배경

Budget evaluator의 판단 책임과 provider 경계의 차단 책임이 섞여 있었고, legacy DefaultLedgerAdvisor.before()가 BLOCK decision을 집행하지 않아 provider 호출이 진행될 수 있었습니다.

변경 내용

  • BudgetDecisionSTATUS / ADMISSION 평가 유형과 committedUsage / projectedUsage를 분리했습니다.
  • DefaultBudgetEvaluator가 BLOCK과 CURRENCY_MISMATCH에서도 예외를 던지지 않고 구조화된 decision을 반환하도록 수정했습니다.
  • projectedUsage >= limit을 BLOCK 경계로 고정했습니다.
  • legacy DefaultLedgerAdvisor.before()가 BLOCK이면 원래 decision을 보존한 BudgetExceededException으로 provider 호출 전에 중단하도록 수정했습니다.
  • BLOCK 뒤 provider 호출, ledger 기록, budget mutation이 발생하지 않는 회귀 테스트를 추가했습니다.
  • notification event와 sample 응답의 후보 포함 사용량 명칭을 projectedUsage로 통일했습니다.
  • 상태 조회는 admission 허가 근거가 아니며 candidate-aware 최종 lifecycle은 #39가 소유함을 Javadoc에 기록했습니다.

제외 범위

Closes #25

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • 새로운 기능

    • 예산 평가에서 확정 사용량과 후보 비용을 포함한 예상 사용량을 구분합니다.
    • 예산 상태 조회와 호출 허용 여부 판단을 명확히 구분합니다.
    • 통화 불일치를 차단 상태로 보고합니다.
    • 예산 알림에 예상 사용량을 표시합니다.
    • 호출 전에 예산 초과 및 통화 불일치 요청을 차단합니다.
  • 버그 수정

    • 차단된 요청이 제공자 호출이나 사용량 기록으로 이어지지 않도록 개선했습니다.

Walkthrough

예산 결정은 확정 사용량과 후보 비용을 포함한 예상 사용량을 분리한다. 평가기는 예외 대신 구조화된 결정을 반환한다. 알림과 샘플 앱은 projectedUsage를 사용한다. Spring AI advisor는 provider 호출 전에 BLOCK과 통화 불일치를 차단한다.

Changes

예산 결정 및 집행

Layer / File(s) Summary
예산 결정 계약과 평가 모델
token-pilot-budget/src/main/..., token-pilot-budget/src/test/...
BudgetDecision에 평가 유형과 committedUsage/projectedUsage가 추가되었다. evaluator는 후보 비용을 반영한 BLOCK/WARN/ALLOW 및 통화 불일치를 결정으로 반환한다.
결정 결과 소비자 갱신
token-pilot-notification/..., token-pilot-sample-app/..., token-pilot-autoconfigure/...
알림 이벤트와 샘플 budget 응답이 projectedUsage를 사용한다. 관련 테스트와 자동구성 테스트가 새 결정 구조를 검증한다.
provider 경계 차단과 검증
token-pilot-spring-ai/src/main/..., token-pilot-spring-ai/src/test/...
DefaultLedgerAdvisorBLOCK이면 BudgetExceededException을 발생시킨다. 통화 불일치이면 IllegalStateException을 발생시키고 provider 호출을 중단한다.
현재 구현 문서 갱신
AGENTS.md
현재 구현된 순수 예산 결정과 레거시 provider-boundary 차단을 기록한다. admission, 예약, 정산의 미구현 범위도 기록한다.

Suggested reviewers: huitaepark

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 BudgetDecision.BLOCK이 provider 호출을 차단하는 핵심 변경을 정확하고 간결하게 설명합니다.
Description check ✅ Passed 설명은 예산 판단, provider 차단, projectedUsage, 테스트 및 제외 범위를 변경 사항과 일치하게 설명합니다.
Linked Issues check ✅ Passed 변경 사항은 #25의 순수 decision, 통화 불일치, projectedUsage 경계 및 provider 차단 요구사항을 충족합니다.
Out of Scope Changes check ✅ Passed 코드와 문서 변경은 #25의 예산 decision, provider 경계 집행, 사용량 명칭 통일 및 회귀 테스트 범위에 포함됩니다.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai
coderabbitai Bot requested a review from HuitaePark July 29, 2026 10:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@AGENTS.md`:
- Line 77: Update the token-pilot-budget entry in the module-status table to
state that it provides pure budget decisions only, without claiming legacy BLOCK
enforcement. Record provider-boundary enforcement as owned by
token-pilot-spring-ai’s DefaultLedgerAdvisor, while preserving the budget
module’s dependency-light design and noting only its actual implemented
capabilities.
- Line 331: Update the legacy budget-flow wording to replace “already-exhausted
status” with “already-exhausted budget decision at the legacy provider
boundary.” Explicitly state that BudgetDecision.EvaluationType.STATUS is a
candidate-free status lookup, not admission authorization or a basis for
permitting provider invocation.

In
`@token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java`:
- Around line 52-75: Extend the parameterized test
한도_미만의_예상_비용을_포함해_ALLOW와_WARN을_판정한다 to verify that store.addCost(...) is never
called after evaluator.evaluate(...). Match the existing no-call verification
used by the BLOCK test while preserving all current assertions.

In
`@token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java`:
- Around line 99-112: Update the decision handling in SampleController so
CURRENCY_MISMATCH is treated as a provider-boundary fail-closed outcome
alongside BLOCK, returning the actual blocked state, projected usage, and limit
instead of "blockedState":"NONE". Add an end-to-end regression test covering the
currency mismatch response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1778ad61-48e8-4a79-9efa-617ce8add5a4

📥 Commits

Reviewing files that changed from the base of the PR and between 776f788 and 48a006a.

📒 Files selected for processing (14)
  • AGENTS.md
  • token-pilot-autoconfigure/src/test/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfigurationTest.java
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetDecision.java
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetEvaluator.java
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/exception/BudgetExceededException.java
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java
  • token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java
  • token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java
  • token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationService.java
  • token-pilot-notification/src/test/java/io/tokenpilot/notification/BudgetNotificationServiceTest.java
  • token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java
  • token-pilot-sample-app/src/test/java/io/tokenpilot/sample/SampleApplicationBudgetE2ETest.java
  • token-pilot-spring-ai/src/main/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisor.java
  • token-pilot-spring-ai/src/test/java/io/tokenpilot/springai/internal/DefaultLedgerAdvisorTest.java

Comment thread AGENTS.md Outdated
Comment thread AGENTS.md Outdated
Comment on lines +52 to +75
@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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

ALLOW/WARN 테스트에 addCost 비호출 검증 누락

BLOCK 테스트(Line 99)에는 verify(store, never()).addCost(...)가 있지만, 이 파라미터라이즈드 테스트에는 동일한 검증이 없습니다. evaluate()가 어떤 상태에서도 store를 변경하지 않는 순수 함수여야 한다는 보장은 ALLOW/WARN 경로에서도 동일하게 중요합니다.

참고로 라인 레인지 변경 요약에는 "두 테스트 모두 ... store.addCost의 비호출을 함께 검증한다"고 되어 있으나, 실제 코드상 이 테스트에는 해당 검증이 빠져 있습니다.

✅ 제안 diff
     assertThat(result.committedUsage()).isEqualTo(usd(committed));
     assertThat(result.projectedUsage()).isEqualTo(usd(projected));
+    verify(store, never()).addCost(any(), any(), any());
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@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`({
"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(committed));
BudgetDecision result = evaluator.evaluate(TAGS, usd(candidate));
assertThat(result.state()).isEqualTo(expectedState);
assertThat(result.threshold()).isEqualTo(expectedThreshold);
assertThat(result.key()).isEqualTo(key("policy-a", "tenant-a", "2026-07"));
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());
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java`
around lines 52 - 75, Extend the parameterized test
한도_미만의_예상_비용을_포함해_ALLOW와_WARN을_판정한다 to verify that store.addCost(...) is never
called after evaluator.evaluate(...). Match the existing no-call verification
used by the BLOCK test while preserving all current assertions.

Comment on lines +99 to +112
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

통화 불일치를 차단되지 않음으로 표시하지 마세요.

CURRENCY_MISMATCH는 provider 경계에서 fail-closed해야 하는 결정인데, 현재는 "blockedState":"NONE"으로 내려갑니다. BLOCK과 함께 실제 상태·사용량·한도를 반환하고 E2E 회귀 테스트를 추가하세요.

수정 예시
-        if (blockedDecision.state() == BudgetState.BLOCK) {
+        if (blockedDecision.state() == BudgetState.BLOCK
+                || blockedDecision.state() == BudgetState.CURRENCY_MISMATCH) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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"
BudgetDecision blockedDecision = evaluator.evaluate(tags, projectedCost);
if (blockedDecision.state() == BudgetState.BLOCK
|| blockedDecision.state() == BudgetState.CURRENCY_MISMATCH) {
return Map.of(
"enabled", "true",
"initialState", initialDecision.state().name(),
"blockedState", blockedDecision.state().name(),
"projectedUsage", CostBoundaryFormatter.format(blockedDecision.projectedUsage()),
"limit", CostBoundaryFormatter.format(blockedDecision.limit())
);
}
return Map.of(
"enabled", "true",
"initialState", initialDecision.state().name(),
"blockedState", "NONE"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java`
around lines 99 - 112, Update the decision handling in SampleController so
CURRENCY_MISMATCH is treated as a provider-boundary fail-closed outcome
alongside BLOCK, returning the actual blocked state, projected usage, and limit
instead of "blockedState":"NONE". Add an end-to-end regression test covering the
currency mismatch response.

@HuitaePark HuitaePark self-assigned this Jul 29, 2026

@HuitaePark HuitaePark left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전체적으로 방향이 좋습니다. evaluator를 순수 decision으로 만들고 provider boundary에서 BLOCK을 집행한 점, 원 decision 보존과 provider/ledger/budget mutation 0을 검증한 점까지 #25의 핵심 계약을 충족합니다.

한 가지 호환성만 확인 부탁드립니다. BudgetNotificationEvent의 record component를 currentUsage에서 projectedUsage로 바꾸면서 외부 BudgetNotificationHandler 구현에서 사용하던 event.currentUsage() accessor가 사라집니다. BudgetDecision에는 migration note가 있지만 notification 공개 API 변경에는 호환 경로나 안내가 없습니다.

가능하면 @Deprecated currentUsage()projectedUsage의 호환 accessor로 한 릴리스 유지해 주세요. 의도적인 breaking change라면 최소한 migration note와 release note에 명시해 주면 좋겠습니다.

@coderabbitai
coderabbitai Bot requested a review from HuitaePark August 3, 2026 15:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@AGENTS.md`:
- Around line 399-401: Update the changelog entry heading associated with the
BudgetNotificationEvent.currentUsage() compatibility note to use the actual
change date, ensuring it is not later than the review date of 2026-08-03; use
2026-08-03 if this records the current change, or omit/defer the entry if the
change occurred on 2026-08-04.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f3e5bc3-509e-45a6-8f4e-42cd5a34db73

📥 Commits

Reviewing files that changed from the base of the PR and between 2618eae and f231305.

📒 Files selected for processing (3)
  • AGENTS.md
  • token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java
  • token-pilot-notification/src/test/java/io/tokenpilot/notification/BudgetNotificationServiceTest.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • token-pilot-notification/src/test/java/io/tokenpilot/notification/BudgetNotificationServiceTest.java

Comment thread AGENTS.md
Comment on lines +399 to +401
### 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

업데이트 이력의 날짜를 실제 변경일과 일치시키세요.

현재 리뷰 기준일은 2026년 8월 3일입니다. 2026-08-04는 미래 날짜입니다. 이 항목이 현재 변경을 기록한다면 2026-08-03 또는 실제 변경일로 수정하세요. 실제 변경일이 2026년 8월 4일이라면 해당 날짜 이후에 항목을 추가하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@AGENTS.md` around lines 399 - 401, Update the changelog entry heading
associated with the BudgetNotificationEvent.currentUsage() compatibility note to
use the actual change date, ensuring it is not later than the review date of
2026-08-03; use 2026-08-03 if this records the current change, or omit/defer the
entry if the change occurred on 2026-08-04.

@Leejaewang03

Copy link
Copy Markdown
Contributor Author
  • record component는 의미가 명확한 projectedUsage로 유지했습니다.
  • 기존 event.currentUsage() 호출이 계속 동작하도록 deprecated 호환 accessor를 추가했습니다.
  • currentUsage()는 내부적으로 projectedUsage()와 동일한 값을 반환합니다.

@HuitaePark
HuitaePark merged commit cc66c5d into main Aug 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Core] BudgetDecision.BLOCK이 provider 호출을 차단하도록 수정

2 participants