Skip to content

[Core/Budget] Money 정밀도·통화 불변식과 최종 반올림 교정#51

Open
Rigu1 wants to merge 8 commits into
tokenpliot:mainfrom
Rigu1:fix/cost-budget-currency-precision
Open

[Core/Budget] Money 정밀도·통화 불변식과 최종 반올림 교정#51
Rigu1 wants to merge 8 commits into
tokenpliot:mainfrom
Rigu1:fix/cost-budget-currency-precision

Conversation

@Rigu1

@Rigu1 Rigu1 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

변경 사항

Cost의 금액/통화 불변식을 Money ADR 기준에 맞게 정리했습니다.

  • Cost canonical constructor에서 valuecurrency null을 거부합니다.
  • usage cost의 음수 금액을 거부합니다.
  • Cost 생성 시 자동 scale 6 반올림을 제거해 내부 정밀도를 보존합니다.
  • Cost.zero()의 암묵적 USD 선택을 제거하고 Cost.zero(currency)로 통화를 명시합니다.
  • Cost.add(...)Cost.compareTo(...)는 같은 통화에서만 허용합니다.
  • scale이 다른 수치상 같은 금액이 동일하게 취급되도록 equals/hashCode를 정리했습니다.
  • DefaultCostCalculator의 1K 단가 계산을 rate.multiply(count).movePointLeft(3) 방식으로 정리했습니다.
  • 외부 경계 최종 반올림 API로 RoundingPolicy.COST_BOUNDARY_ROUNDING을 추가했습니다.
  • Budget의 통화 없는 금액 경계를 Cost 기반 API로 이관했습니다.
  • BudgetDecision, BudgetEvaluator, BudgetStateStore, notification event가 currency-aware 금액을 다루도록 변경했습니다.
  • Budget 평가 중 limit 통화와 요청 cost 통화가 다르면 상태를 변경하지 않고 CURRENCY_MISMATCH를 반환합니다.
  • InMemoryBudgetStateStore는 같은 tags라도 currency가 다르면 누적 상태를 분리합니다.
  • sample app의 HTTP 금액 응답은 CostBoundaryFormatter를 통해 표시 경계 반올림을 한 번만 적용합니다.

배경

기존 구현은 비용 계산 중간 또는 Cost 생성 시점에 반올림을 적용했습니다. 이로 인해 0.0000004 USD처럼 작은 요청 비용이 사라질 수 있고, 요청별 반올림 오차가 누적될 수 있었습니다.

또한 Budget API가 통화 없는 BigDecimal을 사용해 budget limit과 actual/reservation cost의 통화를 안전하게 비교하기 어려웠습니다.

이번 변경은 Money ADR의 정밀도 정책에 맞춰 내부 금액은 반올림 없이 보존하고, 외부 표시/청구 경계에서만 명시적인 정책으로 최종 반올림하도록 정리합니다.

비용 계산 흐름

rate × tokenCount × 10^-3
→ 반올림 없이 Cost로 보존
→ Budget admission은 exact amount로 비교
→ 외부 표시/청구 경계에서 RoundingPolicy.COST_BOUNDARY_ROUNDING 1회 적용

수치 예시

요청 1건 = 0.0000004 USD
1,000건 내부 합계 = 0.0004000 USD
최종 boundary rounding = 0.000400 USD

호환성

  • Cost.of(value) 대신 Cost.of(value, currency)를 사용해야 합니다.
  • Cost.zero() 대신 Cost.zero(currency)를 사용해야 합니다.
  • Cost는 더 이상 생성자에서 scale 6 반올림을 수행하지 않습니다.
  • Budget API의 금액 타입이 BigDecimal에서 Cost로 변경됩니다.
  • BudgetDecision.currentUsage()BudgetDecision.limit()Cost를 반환합니다.
  • BudgetEvaluator.evaluate(tags, cost)Cost를 받습니다.
  • BudgetStateStore.addCost(tags, cost)Cost를 받습니다.
  • BudgetStateStore.getAccumulatedCost(tags, currency)는 명시 currency를 받아 Cost를 반환합니다.
  • 최종 표시/청구용 반올림은 RoundingPolicy.COST_BOUNDARY_ROUNDING을 명시적으로 적용해야 합니다.

범위 메모

현재 코드에 존재하는 Budget operation은 evaluate입니다. reserve, commit, reconcile은 아직 구현된 API가 아니므로, 해당 통화 불일치 처리는 후속 reservation/reconciliation 이슈에서 이어가야 합니다.

체크리스트

필수 테스트

  • 0.0000004 비용이 Cost 생성 시 0이 되지 않는다.
  • 작은 비용을 여러 번 더한 값이 최종 한 번 반올림한 값과 같다.
  • 1,000 token 단위 일반/cache/reasoning 계산이 정확하다.
  • internal value에는 scale 6 강제가 없다.
  • 외부 boundary에서만 scale 6, HALF_UP이 한 번 적용된다.
  • null amount/currency와 음수 usage cost를 거부한다.
  • zero(currency)가 지정 통화를 보존한다.
  • 서로 다른 통화의 add/compare/evaluate는 상태 변경 없이 실패한다.
  • Core와 Budget public API에 통화 없는 새 금액 경계가 남지 않는다.
  • scale이 다른 수치상 같은 금액의 비교 계약이 일관된다.

Acceptance Criteria

  • Core와 Budget 내부에 요청별 조기 반올림이 없다.
  • 모든 budget/accounting 금액이 currency-aware다.
  • 최종 반올림 담당 API와 boundary가 이름으로 드러난다.
  • :token-ledger-core:test, :token-ledger-budget:test가 통과한다.
  • [ADR] TokenUsage와 Money 불변식 및 정밀도 정책 정의 #24의 Money 정밀도/통화 불변식과 구현이 일치한다.

Closes #26

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • 새 기능
    • 비용과 예산 한도에 통화 정보를 명시적으로 적용합니다.
    • 통화가 다른 비용은 별도로 관리하며, 통화 불일치 상태를 제공합니다.
    • 비용 비교·합산 및 경계 반올림 규칙을 강화했습니다.
  • 개선 사항
    • 예산 사용량과 한도가 통화가 포함된 비용 정보로 표시됩니다.
    • 비용 계산의 정밀도가 향상되어 작은 금액도 정확하게 처리됩니다.
    • 예산 초과 및 알림 결과의 금액 표시가 일관되게 개선되었습니다.

Walkthrough

Changes

비용 모델과 예산 API의 금액 타입을 BigDecimal에서 통화 포함 Cost로 변경했습니다. 비용 계산의 중간 반올림을 제거하고 경계 반올림 정책을 추가했으며, 예산 평가·저장·알림·자동 설정·샘플 앱과 관련 테스트를 갱신했습니다.

통화 인식 비용 및 예산 처리

Layer / File(s) Summary
Cost 정밀도와 경계 반올림
token-pilot-core/...
Cost가 통화 검증, 음수 거부, 통화 일치 연산과 비교를 지원하며, 비용 계산은 정확한 decimal shift를 사용하고 경계에서만 반올림합니다.
예산 계약과 통화별 평가
token-pilot-budget/...
예산 결정·평가·저장 계약을 Cost 기반으로 변경하고, 통화별 누적과 CURRENCY_MISMATCH 결과를 추가했습니다.
예산 연동과 비용 이벤트
token-pilot-autoconfigure/..., token-pilot-notification/..., token-pilot-spring-ai/...
자동 설정, 비용 누적, 알림 이벤트와 테스트가 USD Cost 및 새 예산 계약을 사용하도록 변경되었습니다.
샘플 앱 비용 출력
token-pilot-sample-app/...
샘플 예산 입력과 응답을 통화 포함 비용으로 변경하고, 경계 반올림 문자열 출력 및 E2E 테스트를 추가했습니다.

Suggested reviewers: huitaepark

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 제목이 Core/Budget의 금액 정밀도와 통화 불변식, 최종 반올림 교정을 정확히 요약합니다.
Description check ✅ Passed 설명이 변경된 Cost, Budget, rounding 정책과 샘플 앱 반영 내용을 모두 관련성 있게 설명합니다.
Linked Issues check ✅ Passed #26의 핵심 요구인 Cost/Budget 통화 인식, 조기 반올림 제거, CURRENCY_MISMATCH 처리, 경계 반올림이 구현되었습니다.
Out of Scope Changes check ✅ Passed 요약된 변경은 모두 금액 정밀도와 통화 불변식 정리 범위에 맞으며 명백한 범위 외 변경은 보이지 않습니다.
✨ Finishing Touches
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/cost-budget-currency-precision

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

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

@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: 2

🤖 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 `@token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java`:
- Around line 17-19: Introduce an atomic reserve/reconcile contract accepting
the budget window and idempotency key in
token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java:17-19.
Update DefaultBudgetEvaluator in
token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java:27-38
to decide ALLOW/BLOCK from the reservation result, and implement synchronized
per-currency reservations, actual-cost reconciliation, window handling, and
duplicate-key prevention in InMemoryBudgetStateStore at
token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java:24-26.
Extend DefaultBudgetEvaluatorTest at
token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java:95-104
to cover concurrency, budget windows, idempotency, and estimate/actual
reconciliation.

In
`@token-pilot-sample-app/src/main/java/io/tokenpilot/sample/SampleController.java`:
- Around line 89-94: In SampleController’s endpoint flow, move the initial
evaluator.evaluate call into the same try block as the subsequent
evaluator.evaluate call so both invocations are handled by the existing catch
logic. Keep the stateStore.addCost operation and both evaluation arguments
unchanged, and preserve the current response behavior for
BudgetExceededException.
🪄 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: 5e4614fb-8477-4f14-8a0e-3621883b0337

📥 Commits

Reviewing files that changed from the base of the PR and between 16dea43 and 968f2c6.

📒 Files selected for processing (25)
  • token-pilot-autoconfigure/src/main/java/io/tokenpilot/autoconfigure/TokenPilotAutoConfiguration.java
  • 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/BudgetState.java
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/LedgerBudgetComponents.java
  • token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java
  • token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStoreTest.java
  • token-pilot-core/src/main/java/io/tokenpilot/core/RoundingPolicy.java
  • token-pilot-core/src/main/java/io/tokenpilot/core/domain/Cost.java
  • token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultCostCalculator.java
  • token-pilot-core/src/main/java/io/tokenpilot/core/internal/DefaultLedgerManager.java
  • token-pilot-core/src/test/java/io/tokenpilot/core/RoundingPolicyTest.java
  • token-pilot-core/src/test/java/io/tokenpilot/core/domain/CostTest.java
  • token-pilot-core/src/test/java/io/tokenpilot/core/internal/DefaultCostCalculatorTest.java
  • token-pilot-notification/src/main/java/io/tokenpilot/notification/BudgetNotificationEvent.java
  • token-pilot-notification/src/test/java/io/tokenpilot/notification/BudgetNotificationServiceTest.java
  • token-pilot-sample-app/src/main/java/io/tokenpilot/sample/CostBoundaryFormatter.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 on lines +17 to +19
Cost getAccumulatedCost(Map<String, String> tags, Currency currency);

void addCost(Map<String, String> tags, BigDecimal amount);
void addCost(Map<String, String> tags, Cost cost);

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 | 🟠 Major | 🏗️ Heavy lift

원자적 예산 예약 계약을 도입하세요. 현재 조회·평가·누적이 분리되어 동시 요청이 같은 잔여 예산을 보고 모두 BLOCK을 우회할 수 있습니다.

  • token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java#L17-L19: 예산 창과 idempotency key를 받는 원자적 reserve/reconcile 계약을 추가하세요.
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java#L27-L38: 단순 조회 결과 대신 원자적 예약 결과로 허용·차단을 결정하세요.
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java#L24-L26: 통화별 상태에 예약·실제 비용 조정·중복 요청 방지를 구현하세요.
  • token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java#L95-L104: 동시 요청, 예산 창, 중복 요청, estimate/actual reconcile을 검증하도록 바꾸세요.

As per coding guidelines, “Budget enforcement must address BLOCK behavior, budget windows, atomic reservation, idempotency, concurrency, and estimate/actual reconciliation before being treated as complete.”

📍 Affects 4 files
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java#L17-L19 (this comment)
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java#L27-L38
  • token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java#L24-L26
  • token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java#L95-L104
🤖 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/main/java/io/tokenpilot/budget/BudgetStateStore.java`
around lines 17 - 19, Introduce an atomic reserve/reconcile contract accepting
the budget window and idempotency key in
token-pilot-budget/src/main/java/io/tokenpilot/budget/BudgetStateStore.java:17-19.
Update DefaultBudgetEvaluator in
token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluator.java:27-38
to decide ALLOW/BLOCK from the reservation result, and implement synchronized
per-currency reservations, actual-cost reconciliation, window handling, and
duplicate-key prevention in InMemoryBudgetStateStore at
token-pilot-budget/src/main/java/io/tokenpilot/budget/internal/InMemoryBudgetStateStore.java:24-26.
Extend DefaultBudgetEvaluatorTest at
token-pilot-budget/src/test/java/io/tokenpilot/budget/internal/DefaultBudgetEvaluatorTest.java:95-104
to cover concurrency, budget windows, idempotency, and estimate/actual
reconciliation.

Source: Coding guidelines

Comment on lines 89 to +94
Map<String, String> 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

첫 번째 evaluate 호출이 예외 처리 없이 실행되어 반복 호출 시 500 에러 위험.

stateStore는 호출마다 무조건 0.0045를 누적하고(Line 91), Line 90의 evaluator.evaluate(tags, ...)는 try 블록 밖에 있습니다. 실제 실행 환경에서 이 엔드포인트를 두 번째로 호출하면 이전 호출에서 누적된 비용 때문에 Line 90에서도 BudgetExceededException이 발생할 수 있는데, 이는 어디에서도 캐치되지 않아 그대로 500 에러로 전파됩니다. 현재 catch 블록은 Line 94의 두 번째 호출에만 대비되어 있어 비대칭적입니다.

evaluate 호출을 모두 같은 try 블록 안에서 처리하도록 재구성하는 것을 권장합니다.

🛡️ 제안하는 수정
         Map<String, String> tags = Map.of("tenant_id", "budget-sample-tenant");
-        BudgetDecision initialDecision = evaluator.evaluate(tags, Cost.of(new BigDecimal("0.001"), USD));
-        stateStore.addCost(tags, Cost.of(new BigDecimal("0.0045"), USD));
-
         try {
+            BudgetDecision initialDecision = evaluator.evaluate(tags, Cost.of(new BigDecimal("0.001"), USD));
+            stateStore.addCost(tags, Cost.of(new BigDecimal("0.0045"), USD));
             evaluator.evaluate(tags, Cost.of(new BigDecimal("0.001"), USD));
             return Map.of(
                     "enabled", "true",
                     "initialState", initialDecision.state().name(),
                     "blockedState", "NONE"
             );
         } catch (BudgetExceededException exception) {
             BudgetDecision blockedDecision = exception.getDecision();
             return Map.of(
                     "enabled", "true",
-                    "initialState", initialDecision.state().name(),
+                    "initialState", blockedDecision.state().name(),
                     "blockedState", blockedDecision.state().name(),
                     "currentUsage", CostBoundaryFormatter.format(blockedDecision.currentUsage()),
                     "limit", CostBoundaryFormatter.format(blockedDecision.limit())
             );
         }
📝 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
Map<String, String> 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));
Map<String, String> tags = Map.of("tenant_id", "budget-sample-tenant");
try {
BudgetDecision initialDecision = evaluator.evaluate(tags, Cost.of(new BigDecimal("0.001"), USD));
stateStore.addCost(tags, Cost.of(new BigDecimal("0.0045"), USD));
evaluator.evaluate(tags, Cost.of(new BigDecimal("0.001"), USD));
return Map.of(
"enabled", "true",
"initialState", initialDecision.state().name(),
"blockedState", "NONE"
);
} catch (BudgetExceededException exception) {
BudgetDecision blockedDecision = exception.getDecision();
return Map.of(
"enabled", "true",
"initialState", blockedDecision.state().name(),
"blockedState", blockedDecision.state().name(),
"currentUsage", CostBoundaryFormatter.format(blockedDecision.currentUsage()),
"limit", CostBoundaryFormatter.format(blockedDecision.limit())
);
}
🤖 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 89 - 94, In SampleController’s endpoint flow, move the initial
evaluator.evaluate call into the same try block as the subsequent
evaluator.evaluate call so both invocations are handled by the existing catch
logic. Keep the stateStore.addCost operation and both evaluation arguments
unchanged, and preserve the current response behavior for
BudgetExceededException.

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/Budget] Money 정밀도·통화 불변식과 최종 반올림 교정

1 participant