Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Spring AI 애플리케이션에서 AI 호출 토큰 사용량과 비용을 기

- 모델별 가격 정책: `PricingPlan`
- 토큰 타입별 사용량: `PROMPT`, `COMPLETION`, `REASONING`, `CACHED_PROMPT`, `CACHED_COMPLETION`
- 비용 계산: 1K 토큰당 단가 기반, 최종 비용 소수점 6자리 반올림
- 비용 계산: 1K 토큰당 단가 기반으로 내부 정밀도를 보존하고, 외부 경계에서 `RoundingPolicy`로 명시적으로 반올림
- 비용 기록 이벤트: `CostRecordedEvent`
- 리스너 기반 확장: `LedgerListener`
- Spring AI 응답 usage 추출: `UsageExtractor`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
package io.tokenledger.budget;

import java.math.BigDecimal;
import io.tokenledger.core.domain.Cost;


/**
* 예산 평가 결과를 나타내는 값 객체입니다.
* <p>
* 호출 가능 여부와
* 판단에 필요한 최소한의 정보를 담습니다.
* 호출 가능 여부와 판단에 필요한 최소한의 정보를 담습니다.
*/

public record BudgetDecision(
BudgetState state,
String reason,
BigDecimal currentUsage,
BigDecimal limit
BudgetState state,
String reason,
Cost currentUsage,
Cost limit
) {}
Original file line number Diff line number Diff line change
@@ -1,28 +1,26 @@
package io.tokenledger.budget;

import java.math.BigDecimal;
import io.tokenledger.core.domain.Cost;
import java.util.Map;


/**
* AI 호출 전 예산 초과 여부를 판단하는 인터페이스입니다.
* <p>
* 구현체는 현재까지 누적된 비용과
* 이번 호출로 발생할 비용을 기준으로
* 호출을 허용하거나 차단하는 역할을 합니다.
* 구현체는 현재까지 누적된 비용과 이번 호출로 발생할 비용을 기준으로 호출을 허용하거나 차단하는 역할을 합니다.
*/
public interface BudgetEvaluator {

/**
* 단순히 현재의 누적 비용이 예산 한도를 초과했는지만 판단합니다. (부수 효과 없음)
*/
BudgetDecision evaluate(Map<String, String> tags);
/**
* 단순히 현재의 누적 비용이 예산 한도를 초과했는지만 판단합니다. (부수 효과 없음)
*/
BudgetDecision evaluate(Map<String, String> tags);

/**
* 이번 호출로 발생할 예상 비용을 포함하여 예산 초과 여부를 판단합니다. (부수 효과 없음)
*/
BudgetDecision evaluate(
Map<String, String> tags,
BigDecimal costAmount
);
/**
* 이번 호출로 발생할 예상 비용을 포함하여 예산 초과 여부를 판단합니다. (부수 효과 없음)
*/
BudgetDecision evaluate(
Map<String, String> tags,
Cost cost
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@
public enum BudgetState {
ALLOW,
WARN,
BLOCK
BLOCK,
CURRENCY_MISMATCH
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.tokenledger.budget;

import java.math.BigDecimal;
import io.tokenledger.core.domain.Cost;
import java.util.Currency;
import java.util.Map;


Expand All @@ -13,7 +14,7 @@

public interface BudgetStateStore {

BigDecimal getAccumulatedCost(Map<String, String> tags);
Cost getAccumulatedCost(Map<String, String> tags, Currency currency);

void addCost(Map<String, String> tags, BigDecimal amount);
void addCost(Map<String, String> tags, Cost cost);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,89 +3,112 @@
import io.tokenledger.budget.*;
import io.tokenledger.budget.exception.BudgetExceededException;

import io.tokenledger.core.domain.Cost;
import java.math.BigDecimal;
import java.util.Map;

/**
* BudgetEvaluator의 기본 구현체입니다.
*
* <p>
* 판단 기준:
* - 80% 미만 → ALLOW
* - 80% 이상 → WARN
* - 100% 이상 → BLOCK (예외 발생)
*
* - 통화 불일치 → CURRENCY_MISMATCH
* <p>
* 이 클래스의 evaluate 메서드는 부수 효과가 없는 순수 함수로 동작합니다.
* 실제 비용 누적은 BudgetStateStore.addCost를 통해 별도로 수행해야 합니다.
*/
public class DefaultBudgetEvaluator implements BudgetEvaluator {

private final BudgetStateStore store;
private final BigDecimal monthlyLimit;
private static final BigDecimal WARN_THRESHOLD_RATE = new BigDecimal("0.8");

public DefaultBudgetEvaluator(
BudgetStateStore store,
BigDecimal monthlyLimit
) {
this.store = store;
this.monthlyLimit = monthlyLimit;
}
private final BudgetStateStore store;
private final Cost monthlyLimit;

@Override
public BudgetDecision evaluate(Map<String, String> tags) {
return evaluate(tags, BigDecimal.ZERO);
}
public DefaultBudgetEvaluator(
BudgetStateStore store,
Cost monthlyLimit
) {
this.store = store;
this.monthlyLimit = monthlyLimit;
}

@Override
public BudgetDecision evaluate(Map<String, String> tags) {
return evaluate(tags, Cost.zero(monthlyLimit .currency()));
}

@Override
public BudgetDecision evaluate(
Map<String, String> tags,
BigDecimal costAmount
) {
@Override
public BudgetDecision evaluate(
Map<String, String> tags,
Cost cost
) {

// ✅ 현재까지 누적 비용
BigDecimal accumulated = store.getAccumulatedCost(tags);
// ✅ 현재까지 누적 비용
Cost accumulated = store.getAccumulatedCost(tags, monthlyLimit.currency());

// ✅ 이번 호출까지 포함한 비용 (비교용)
BigDecimal nextUsage = accumulated.add(costAmount);
if (hasCurrencyMismatch(accumulated) || hasCurrencyMismatch(cost)) {
return currencyMismatchDecision(accumulated);
}

// ✅ 경고 기준 (80%)
BigDecimal warnThreshold =
monthlyLimit.multiply(new BigDecimal("0.8"));
// ✅ 이번 호출까지 포함한 비용 (비교용)
Cost nextUsage = accumulated.add(cost);

// ✅ 경고 기준 (80%)
Cost warnThreshold = Cost.of(
monthlyLimit.amount().multiply(WARN_THRESHOLD_RATE),
monthlyLimit.currency()
);

/* =====================
1️⃣ 차단 (BLOCK)
===================== */
if (nextUsage.compareTo(monthlyLimit) >= 0) {
if (nextUsage.compareTo(monthlyLimit) >= 0) {

BudgetDecision decision = new BudgetDecision(
BudgetState.BLOCK,
"월 예산 초과로 AI 호출이 차단되었습니다",
nextUsage,
monthlyLimit
);
BudgetDecision decision = new BudgetDecision(
BudgetState.BLOCK,
"월 예산 초과로 AI 호출이 차단되었습니다",
nextUsage,
monthlyLimit
);

throw new BudgetExceededException(decision);
}
throw new BudgetExceededException(decision);
}

/* =====================
2️⃣ 경고 (WARN)
===================== */
if (nextUsage.compareTo(warnThreshold) >= 0) {
return new BudgetDecision(
BudgetState.WARN,
"월 예산의 80%에 도달했습니다",
nextUsage,
monthlyLimit
);
}
if (nextUsage.compareTo(warnThreshold) >= 0) {
return new BudgetDecision(
BudgetState.WARN,
"월 예산의 80%에 도달했습니다",
nextUsage,
monthlyLimit
);
}

/* =====================
3️⃣ 허용 (ALLOW)
===================== */
return new BudgetDecision(
BudgetState.ALLOW,
"예산 범위 내입니다",
nextUsage,
monthlyLimit
);
}
return new BudgetDecision(
BudgetState.ALLOW,
"예산 범위 내입니다",
nextUsage,
monthlyLimit
);
}

private boolean hasCurrencyMismatch(Cost cost) {
return !monthlyLimit.currency().equals(cost.currency());
}

private BudgetDecision currencyMismatchDecision(Cost currentUsage) {
return new BudgetDecision(
BudgetState.CURRENCY_MISMATCH,
"예산 통화와 비용 통화가 일치하지 않습니다",
currentUsage,
monthlyLimit
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,36 @@

import io.tokenledger.budget.BudgetStateStore;

import io.tokenledger.core.domain.Cost;
import java.math.BigDecimal;
import java.util.Currency;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;


/**
* BudgetStateStore의 인메모리 기반 구현체입니다.
*
* 예산 사용량을 메모리 내에서 누적 관리하며,
* 테스트 및 간단한 실행 환경을 위한 구현입니다.
* <p>
* 예산 사용량을 메모리 내에서 누적 관리하며, 테스트 및 간단한 실행 환경을 위한 구현입니다.
*/

public class InMemoryBudgetStateStore implements BudgetStateStore {

private final Map<String, BigDecimal> store = new ConcurrentHashMap<>();
private final Map<String, Cost> store = new ConcurrentHashMap<>();

private String key(Map<String, String> tags) {
return tags.getOrDefault("tenant_id", "default");
}
private String key(Map<String, String> tags) {
return tags.getOrDefault("tenant_id", "default");
}

@Override
public BigDecimal getAccumulatedCost(Map<String, String> tags) {
// 아직 사용 기록이 없으면 0원
return store.getOrDefault(key(tags), BigDecimal.ZERO);
}
@Override
public Cost getAccumulatedCost(Map<String, String> tags, Currency currency) {
return store.getOrDefault(key(tags), Cost.zero(currency));
}
Comment on lines +28 to +30

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files
git ls-files | rg 'token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java|token-ledger-budget/src/main/java/io/tokenledger/budget|Cost.java|DefaultBudgetEvaluator.java'

# Map the target file structure
ast-grep outline token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java --view expanded

# Inspect the target file with line numbers
wc -l token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java
cat -n token-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java

# Find the Cost implementation and evaluator
rg -n "class Cost|record Cost|interface Cost|currency\(\)|add\(|CURRENCY_MISMATCH|evaluate\(" token-ledger-budget/src/main/java -S

# Print the relevant Cost and evaluator files if found
for f in $(git ls-files 'token-ledger-budget/src/main/java/**' | rg 'Cost\.java|DefaultBudgetEvaluator\.java'); do
  echo "===== $f ====="
  wc -l "$f"
  cat -n "$f"
done

Repository: tokenpliot/tokenpilot

Length of output: 8069


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect Cost and store contract
wc -l token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java \
      token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java

cat -n token-ledger-core/src/main/java/io/tokenledger/core/domain/Cost.java
printf '\n===== BudgetStateStore =====\n'
cat -n token-ledger-budget/src/main/java/io/tokenledger/budget/BudgetStateStore.java

# Check whether addCost/getAccumulatedCost are used elsewhere in a way that depends on currency matching
rg -n "addCost\\(|getAccumulatedCost\\(" token-ledger-budget token-ledger-core -S

Repository: tokenpliot/tokenpilot

Length of output: 5166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect relevant tests and usages around currency mismatch
rg -n "CURRENCY_MISMATCH|currency mismatch|addCost\\(|getAccumulatedCost\\(" token-ledger-budget/src/test token-ledger-budget/src/main -S

# Show the DefaultBudgetEvaluator tests around the store interaction
wc -l token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java
cat -n token-ledger-budget/src/test/java/io/tokenledger/budget/internal/DefaultBudgetEvaluatorTest.java

# Probe JDK merge/remapping exception behavior with a tiny standalone program
cat >/tmp/merge_probe.java <<'EOF'
import java.util.concurrent.ConcurrentHashMap;
import java.util.*;

public class merge_probe {
  public static void main(String[] args) {
    var map = new ConcurrentHashMap<String, String>();
    map.put("k", "USD");
    try {
      map.merge("k", "EUR", (a,b) -> { throw new IllegalArgumentException("boom"); });
      System.out.println("no-throw");
    } catch (Exception e) {
      System.out.println(e.getClass().getName() + ":" + e.getMessage());
    }
    System.out.println(map.get("k"));
  }
}
EOF
javac /tmp/merge_probe.java && java -cp /tmp merge_probe

Repository: tokenpliot/tokenpilot

Length of output: 7744


InMemoryBudgetStateStore#getAccumulatedCost/addCost(28-35)에서 통화도 상태 키에 포함하세요

key(tags)tenant_id만 써서 같은 태그에 서로 다른 통화가 섞일 수 있습니다.

  • getAccumulatedCost(tags, currency)currency를 무시해 요청한 통화와 다른 Cost를 그대로 반환할 수 있습니다.
  • 그 상태에서 DefaultBudgetEvaluator.evaluate()accumulated.add(cost)Cost.add()의 통화 검증에서 예외를 던질 수 있습니다.
  • addCost()만 검증해도 읽기 경로가 남으니, 통화를 키에 포함하거나 조회/추가 모두에서 동일하게 차단하세요.
🤖 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-ledger-budget/src/main/java/io/tokenledger/budget/internal/InMemoryBudgetStateStore.java`
around lines 28 - 30, Update InMemoryBudgetStateStore#getAccumulatedCost and
addCost so the state key includes both the tenant tags and currency, using the
same key construction for reads and writes. Ensure requests for different
currencies cannot share or return the same accumulated Cost.


@Override
public void addCost(Map<String, String> tags, BigDecimal amount) {
// 기존 값에 amount를 더함
store.merge(key(tags), amount, BigDecimal::add);
}
@Override
public void addCost(Map<String, String> tags, Cost cost) {
store.merge(key(tags), cost, Cost::add);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import io.tokenledger.budget.BudgetEvaluator;
import io.tokenledger.budget.BudgetStateStore;
import io.tokenledger.core.domain.Cost;

/**
* 예산 제어 컴포넌트 생성을 위한 팩토리 클래스입니다.
Expand All @@ -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);
}
}
Loading